Compare commits

...

173 Commits
v5.14.3 ... v

Author SHA1 Message Date
Greyson Parrelli
8004565c84 Bump version to 5.17.1 2021-07-16 15:55:54 -04:00
Greyson Parrelli
a101dc4fd1 Updated language translations. 2021-07-16 15:53:42 -04:00
Alex Hart
57f730d8ee Fix cursor issue for non-signal-contact searches. 2021-07-16 16:34:38 -03:00
Alex Hart
3543cc80ba Don't show SMS label for push groups. 2021-07-16 16:34:02 -03:00
Greyson Parrelli
71613d9db1 Ensure SQLCipher libraries are loaded. 2021-07-16 14:12:00 -04:00
Greyson Parrelli
4a0e6a3eb2 Improve logging around message sends. 2021-07-16 12:53:29 -04:00
Alex Hart
f1a87518e1 Fix contact search query returning outdated or bad recipients. 2021-07-16 13:53:17 -03:00
Alex Hart
61f880fd78 Hide all media for new conversations. 2021-07-16 13:20:30 -03:00
Greyson Parrelli
09904e7a16 Remove GIF button from attachment keyboard.
We've had it there for ~45 days for education purposes, but we can
remove it now.
2021-07-16 11:01:10 -04:00
Alex Hart
94658e9090 Fix bug where marquee text stopped scrolling. 2021-07-16 09:23:47 -03:00
Greyson Parrelli
a47448b6c6 Bump version to 5.17.0 2021-07-15 16:41:11 -04:00
Greyson Parrelli
7e4b9b685a Updated language translations. 2021-07-15 16:40:28 -04:00
lucio-signal
64922a8e51 Fix custom vibration settings. 2021-07-15 16:29:43 -04:00
Cody Henthorne
f65f4704c9 Improve routine around bulk attachment deletion. 2021-07-15 16:29:11 -04:00
Greyson Parrelli
b04ca202f6 Fix ApplicationMigrations UI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
83086a5a2b Make Sms/MmsDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
51a521594f Fix crash when deleting threads directly after backup restore. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
0a7a7cf5a9 Fix envelope type conversion. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
6bd689504c Make internal recipient details selectable. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
efec40ff57 Fix crash with GV2 group repair during storage sync. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
69716dde4a Fix navigation directly to the help screen. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
e90fa05d60 Update recipient merging. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
580c000bda Move distribution message processing into the decryption phase. 2021-07-15 16:28:13 -04:00
lucio-signal
2f3d04d3e8 Add EmojiFilter to SearchView input field. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
bf37d412e9 Add message trimming info to the debug log. 2021-07-15 16:28:13 -04:00
Alex Hart
fd115ebb72 Drop voice notes that do not have a URI. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
b9657208fe Make ThreadDatabase ID's autoincrement. 2021-07-15 16:28:13 -04:00
Cody Henthorne
5d6d78a51e Initial WebSocket refactor. 2021-07-15 16:28:13 -04:00
Cody Henthorne
916006e664 Tweak sizes and padding of various keyboard elements. 2021-07-15 16:28:13 -04:00
Cody Henthorne
55c69cd50a Add additional fallback logic for change dialog. 2021-07-15 16:28:13 -04:00
Cody Henthorne
14565b0864 Fix crash when building notification state for messages without threads. 2021-07-15 16:28:13 -04:00
Alex Hart
a157c1ae1d Refresh contact search views. 2021-07-15 16:28:13 -04:00
Greyson Parrelli
a4d458f969 Use current tag for nightly versionName. 2021-07-15 16:28:13 -04:00
Alex Hart
3f53abedab Migrate to new Share APIs. 2021-07-15 16:28:13 -04:00
Jordan Rose
68a2d5ed20 Reimplement ProfileCipherInputStream using libsignal-client.
libsignal-client provides an AES-GCM streaming interface that can
replace the implementation in AES-GCM-Provider. Using it from
ProfileCipherInputStream requires some knowledge about the tag size of
AES-GCM, but frees it from the JCE interface.

Note that it remains a serious error to not read the *entire* stream,
since the authentication tag is at the end!
2021-07-15 16:28:11 -04:00
Jordan Rose
35e9e31a7b Update to libsignal-client 0.8.3
This also fixes a misalignment where signal-client-android was on
0.8.0 but signal-client-java was 0.8.1, which was fortunately harmless
for this particular pair of versions.
2021-07-12 20:29:07 -04:00
Cody Henthorne
444d947743 Add RxJava. 2021-07-12 20:29:07 -04:00
Cody Henthorne
c427dbad08 Bump version to 5.16.3 2021-07-12 20:27:40 -04:00
Cody Henthorne
2cefe813e4 Updated language translations. 2021-07-12 20:17:43 -04:00
Alex Hart
123ffe42c3 Fix crash saving a FLAC file. 2021-07-12 13:37:59 -03:00
Alex Hart
da20e66ecd Fix issue where shared contact render would not hide audio view. 2021-07-12 13:24:04 -03:00
Alex Hart
901440017a Fix audio view width on very narrow screens. 2021-07-12 13:20:56 -03:00
Cody Henthorne
0be76a37fe Bump version to 5.16.2 2021-07-09 15:43:11 -04:00
Cody Henthorne
36dadc8777 Updated language translations. 2021-07-09 15:37:19 -04:00
Cody Henthorne
182749c101 Fix bug where some profile fetches would 400 over the websocket. 2021-07-09 15:30:08 -04:00
Alex Hart
d9228bd911 Fix issue where compose views still display under draft. 2021-07-09 15:30:08 -04:00
Greyson Parrelli
a361fcc8f3 Add additional logging to media send jobs. 2021-07-09 15:30:08 -04:00
Alex Hart
ff4f0b9f42 Stop voice note playback after user locks Signal. 2021-07-09 15:30:07 -04:00
Alex Hart
060dffc9cc Fix crash caused when quote draft left and re-entered. 2021-07-09 15:30:07 -04:00
Alex Hart
172cc302fc Add warning dialog for chat color deletion with no uses. 2021-07-09 15:30:07 -04:00
Alex Hart
416e62112f Refresh shared media screens. 2021-07-09 15:30:07 -04:00
Alex Hart
e584a90f81 Fix several voice note beta bugs.
* Sim label positioning
* Bad player state when navigating to and from conversations
* Scrolling date header placement
2021-07-09 15:29:40 -04:00
Cody Henthorne
9876ffb5e4 Bump version to 5.16.1 2021-07-08 17:52:50 -04:00
Cody Henthorne
53e10f2cad Updated language translations. 2021-07-08 17:43:16 -04:00
Cody Henthorne
cb79f75ac1 Fix bug when calling non-Signal contacts from Settings.
Fixes #11450
2021-07-08 17:36:14 -04:00
Cody Henthorne
5ec9c1cd90 Fix crash when saving media with octet stream content type. 2021-07-08 17:36:14 -04:00
Greyson Parrelli
1f28a30ace Add a nightly build type. 2021-07-08 17:36:14 -04:00
Alex Hart
7715917436 Fix issue where position would not update in draft. 2021-07-08 17:36:14 -04:00
Alex Hart
f79b445fdf Fix issue where drafts might not be properly deleted. 2021-07-08 17:36:14 -04:00
Alex Hart
14484deabe Implement count-down in inline player. 2021-07-08 17:36:14 -04:00
Alex Hart
3ac395d33e Fix row item size issue with huge fonts. 2021-07-08 17:36:14 -04:00
Alex Hart
f83b520ca9 Bump version to 5.16.0 2021-07-07 14:58:51 -03:00
Alex Hart
0123f9aa87 Updated language translations. 2021-07-07 14:58:51 -03:00
Alex Hart
06b64fe619 Add inline voice note player to conversation and conversation list. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
1bb87834d8 Reduce recipient resolves in MessageContentProcessor. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
ae4167ddae Write to RecipientIdCache on cache miss. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
383beafdef Move 'you' to end of unnamed groups. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
062e88b24f Rotate sender key flag. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
8299d49042 Show an error for internal users for decryption failures. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
4677883838 Improve mapping SignalServiceAddresses to Recipients. 2021-07-07 14:58:51 -03:00
Greyson Parrelli
7f0a0bef5a Incrementally insert MSL entries for legacy group sends. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
acc825971b Handle additional places where MSL entries need to be deleted. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
62040d06b4 Create a write-through cache for PendingRetryReceiptDatabase. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
0921ebe5f1 Add read and viewed receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
3d0e15e2b8 Add delivery receipts to the MSL. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
5372f79c40 Allow for MSL entries to be associated with multiple messages. 2021-07-07 14:58:50 -03:00
Christian
92e8f9de0e Do not collapse list to hide only one entry. 2021-07-07 14:58:50 -03:00
Christian
c3cf846a10 Fix OutdatedBuildReminder duration.
Fixes #11438
2021-07-07 14:58:50 -03:00
Alex Hart
5826b0c068 Implement drafts for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
2d7c043398 Implement a playback speed toggle for voice notes. 2021-07-07 14:58:50 -03:00
Alex Hart
e20d6b63cf Fix adaptive shortcut icon shapes. 2021-07-07 14:58:50 -03:00
Cody Henthorne
b85c5eb54a Make it more likely 8 emoji fit on a row, fix emoji search emoticons. 2021-07-07 14:58:50 -03:00
Greyson Parrelli
a1c8573fad Insert resent messages at the proper location. 2021-07-07 14:58:50 -03:00
Cody Henthorne
90a27d2227 Fix device transfer test dependent on native library. 2021-07-07 14:58:50 -03:00
Cody Henthorne
c54c6018b2 Remove dead keyboard code after refresh. 2021-06-30 16:13:42 -04:00
Rainer Matischek
7419570f94 Fix rotation not updated on phones using 'Legacy API'.
Fixes #10940
2021-06-30 16:13:42 -04:00
Jim Gustafson
8860f792c4 Update to RingRTC v2.10.6 2021-06-30 16:13:42 -04:00
Greyson Parrelli
e47db0d532 Ensure recipients added to the cache have an identifier. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
ab5d3badc2 Enable WAL mode. 2021-06-30 16:13:42 -04:00
Greyson Parrelli
fce362960f Switch to LinkifyCompat.
We've seen some inconsistencies across OEMs with Linkify. Hopefully
LinkifyCompat will resolve them.
2021-06-30 16:13:42 -04:00
Greyson Parrelli
5bf23dcfb3 Bump version to 5.15.6 2021-06-30 16:12:58 -04:00
Greyson Parrelli
65c7dc6ca2 Updated language translations. 2021-06-30 16:12:36 -04:00
Greyson Parrelli
e30a8b6954 Use proper EmojiTextView in conversation settings toolbar. 2021-06-30 15:43:21 -04:00
Alex Hart
838e318200 Fix edit profile theming issue and mute until issue. 2021-06-30 11:11:35 -03:00
Greyson Parrelli
62ee411901 Bump version to 5.15.5 2021-06-29 14:16:06 -04:00
Greyson Parrelli
ceefd2d92f Updated language translations. 2021-06-29 14:15:28 -04:00
Cody Henthorne
e3870f5656 Fix Customize Reactions shadow. 2021-06-29 12:38:40 -04:00
Alex Hart
6e7022ab70 Fix custom notifications toggle and enable copy phone number on long press. 2021-06-29 11:19:51 -03:00
Alex Hart
031d1551e7 Prevent crash by ignoring call if view is null. 2021-06-29 11:02:57 -03:00
Greyson Parrelli
6755b25361 Bump version to 5.15.4 2021-06-28 18:07:36 -04:00
Greyson Parrelli
11d0a73675 Updated language translations. 2021-06-28 18:07:36 -04:00
Alex Hart
44119b6437 Do not crash if we try to access an item outside of the bounds of the conversation. 2021-06-28 18:07:36 -04:00
Cody Henthorne
d4a3b442f4 Add vertical scrolling to Sticker Keyboard. 2021-06-28 18:07:36 -04:00
Cody Henthorne
aba5774446 Fix share contact list updating improperly on selection change. 2021-06-28 18:07:36 -04:00
Alex Hart
911dd9efb1 Fix conversation media overview underline flicker. 2021-06-28 11:38:14 -03:00
Alex Hart
f2a490b07e Fix several conversation settings feedback issues.
* Mute icon in wrong location in RTL
* No exit animation when dismissing conversation settings
* Thumbnails flicker when you come back to conversation settings
* Rounded corners for mute dialog don't match other dialogs
* Mute button in note-to-self conversation settings
* Explore adding contact details to the contact bottom sheet
2021-06-28 11:11:57 -03:00
Cody Henthorne
5675f080f2 Fix text emoticons not showing up in recents. 2021-06-28 10:11:01 -04:00
Greyson Parrelli
f0dbe230b5 Bump version to 5.15.3 2021-06-25 17:41:53 -04:00
Greyson Parrelli
8b81800052 Updated language translations. 2021-06-25 17:41:53 -04:00
Greyson Parrelli
f598c14298 Update the sender key feature flag. 2021-06-25 17:41:53 -04:00
Greyson Parrelli
58b070e6e3 Fix job info log formatting. 2021-06-25 17:25:59 -04:00
Greyson Parrelli
71c92a1c90 Fix syncing group messages when you're the only member. 2021-06-25 17:00:14 -04:00
Greyson Parrelli
b86acb9773 Increase log size for internal users. 2021-06-25 16:55:15 -04:00
Greyson Parrelli
1b8758b657 Fix text wrapping issues in message details. 2021-06-25 16:49:48 -04:00
Cody Henthorne
ed4bab1b8b Add vertical scroll to Emoji Keyboard. 2021-06-25 16:39:04 -04:00
Greyson Parrelli
a71fe0fd75 Fix issue with group creation on linked devices. 2021-06-25 16:35:42 -04:00
Alan Evans
3d2a634aac Apply the ringer volume to the join/hangup sounds with 15% minimum. 2021-06-25 16:46:59 -03:00
Alex Hart
01047f0546 Apply style changes to shared media, color icon, and wallpaper previews. 2021-06-25 14:27:51 -03:00
Alex Hart
9dac5691f0 Fix issue where all content would be displayed if thread id was -1. 2021-06-25 11:28:28 -03:00
Alex Hart
3c489ad247 Check admin status in areContentsTheSame. 2021-06-25 11:16:53 -03:00
Alex Hart
7797351341 Fix see more icon tint and fix recipient bottom sheet scroll. 2021-06-25 11:09:49 -03:00
Alex Hart
f7212b9916 Update legacy text and fix small animation bug. 2021-06-25 10:57:58 -03:00
Alex Hart
93bb49dc16 Fix inconsistent toolbar animation state on back. 2021-06-25 10:35:07 -03:00
Alex Hart
e504c490c8 Prevent all menu invalidations if we have requested a conversation search. 2021-06-25 09:28:55 -03:00
Cody Henthorne
42e865813c Bump version to 5.15.2 2021-06-24 16:49:02 -04:00
Cody Henthorne
fc14d1d464 Updated language translations. 2021-06-24 16:45:50 -04:00
Cody Henthorne
2a1e5e4471 Add React With Any Search and update UX. 2021-06-24 16:36:13 -04:00
Alex Hart
da2ee33dff Refactor conversation settings screens into a single fragment with new UI. 2021-06-24 16:36:13 -04:00
Greyson Parrelli
f19033a7a2 Implement the message send log for sender key retries. 2021-06-24 16:36:13 -04:00
Greyson Parrelli
6502ef64ce Read the group history response as a stream. 2021-06-23 17:47:05 -04:00
Alan Evans
b3ebf778fd Group call server selection for internal users. 2021-06-23 17:50:59 -03:00
Cody Henthorne
1dca3698d2 Fix crash when adding person to an existing mms group. 2021-06-22 17:03:20 -04:00
Cody Henthorne
2bfe1198d1 Bump version to 5.15.1 2021-06-21 20:24:07 -04:00
Cody Henthorne
4f704670b1 Updated language translations. 2021-06-21 20:01:03 -04:00
Cody Henthorne
a1aafd7453 Fix incorrect mark as read behavior when leaving conversation. 2021-06-21 19:55:02 -04:00
Alex Hart
4932623937 Allow FABs to go as high as the bottom of the toolbar on the conversation list. 2021-06-21 19:55:02 -04:00
Alex Hart
b93568d9c6 Invoke onTick immediately in onResume. 2021-06-21 19:55:02 -04:00
Alex Hart
b3041ab6e0 Always update ViewOnceState before rendering hud. 2021-06-21 14:27:28 -03:00
Alex Hart
3a151b30ac Catch MediaCodecException in extractThumbnails for configuration crash. 2021-06-21 14:19:11 -03:00
Alex Hart
97b3d36433 Add support to MessageDetailsActivity for viewed reciepts. 2021-06-21 14:11:36 -03:00
Cody Henthorne
81e3252128 Do not apply universal timer to SMS chats. 2021-06-21 11:04:28 -04:00
Cody Henthorne
426c83c6cc Fix baby emoji in Help and Profile. 2021-06-21 10:54:55 -04:00
Greyson Parrelli
b427754a81 Fix quoted media rendering issue. 2021-06-21 10:31:14 -04:00
Cody Henthorne
08f023fb12 Revert "Fix ANR when leaving MediaPreviewActivity."
This reverts commit 8be659c1c8.
2021-06-21 09:55:40 -04:00
Greyson Parrelli
5f1454aeb8 Improve the performance of detecting duplicate messages.
To do this, we do two things:
- Make the index on DATE_SENT also include the other relevant fields:
the recipientId and threadId
- Use the most minimal projection possible
2021-06-21 09:51:51 -04:00
Greyson Parrelli
0d254e0724 Fix the mock data initializer.
Needed to ignore the emoji_data FTS tables.
2021-06-20 17:36:27 -04:00
Cody Henthorne
e882e6e111 Bump version to 5.15.0 2021-06-18 15:21:41 -04:00
Cody Henthorne
4b0811f9aa Revert "Temporarily block payments in all regions."
This reverts commit 4637e1b5d8.
2021-06-18 15:10:29 -04:00
Greyson Parrelli
817f1ee938 Add a feature flag to disable SMS megaphone.
As part of this work, we also make sure we fetch feature flags during
registration.
2021-06-18 15:10:16 -04:00
Cody Henthorne
2d93d74b9f Fix incorrect linting by preventing Github Actions from using Android S. 2021-06-18 15:10:16 -04:00
Greyson Parrelli
93f37ad70f Reduce fetches when you open a conversation. 2021-06-18 15:10:16 -04:00
Cody Henthorne
3c6bed90db Fix ANR by upgrading Firebase Messaging. 2021-06-18 15:10:16 -04:00
Greyson Parrelli
fa26fb6b8b Improve conversation query performance.
For the conversation query at least, we stopped joining on the
attachments tables, and instead get attachments on a page-by-page basis.
2021-06-18 15:10:15 -04:00
Cody Henthorne
263ddb0d1e Fix main thread recipient resolve in contact selection. 2021-06-18 15:10:15 -04:00
Cody Henthorne
8be659c1c8 Fix ANR when leaving MediaPreviewActivity. 2021-06-18 15:10:15 -04:00
Cody Henthorne
e5c9dddb5a Fix ANR when generating group message snippets. 2021-06-18 15:10:15 -04:00
Greyson Parrelli
6da72aad6d Log the build variant. 2021-06-18 15:10:15 -04:00
Greyson Parrelli
5dd5a024c9 Narrow locking in LiveRecipientCache.
This should make it so that we never hold a lock while accessing the
database.
2021-06-18 15:10:15 -04:00
Greyson Parrelli
c0eac5564c Clean up message processing locks. 2021-06-18 15:10:15 -04:00
Cody Henthorne
0d0ee753df Make portrait bubbled keyboard height dynamic based on bubble height. 2021-06-18 15:10:15 -04:00
Aaron Labiaga
908f952893 Update API for Activity in bubble check. 2021-06-18 15:10:15 -04:00
Cody Henthorne
1c80e65c5a Bump version to 5.14.5 2021-06-18 15:02:33 -04:00
Cody Henthorne
20b13a929b Updated language translations. 2021-06-18 14:55:16 -04:00
Alex Hart
4637e1b5d8 Temporarily block payments in all regions. 2021-06-18 14:47:32 -04:00
Greyson Parrelli
4b6cb79c75 Fix message exception handling. 2021-06-18 13:52:31 -04:00
Greyson Parrelli
feaf2a33a9 Bump version to 5.14.4 2021-06-17 17:39:30 -04:00
Greyson Parrelli
4c893a11fc Updated language translations. 2021-06-17 17:39:30 -04:00
Cody Henthorne
f4dd80c929 Switch logic order for detecting conversation channel changes. 2021-06-15 13:09:11 -04:00
Cody Henthorne
4af078007e Attempt to recover from encountering octet stream media. 2021-06-15 11:54:14 -04:00
Greyson Parrelli
be297120a1 Include 'you' in dynamic group name. 2021-06-15 11:37:28 -04:00
Cody Henthorne
a9741cadbf Fix logging around dialog flow. 2021-06-15 11:31:56 -04:00
Cody Henthorne
79200c82da Fix create bubble conversation notification. 2021-06-14 16:51:18 -04:00
Cody Henthorne
d9c9ae8dae Update MobileCoin dependency and add new configuration. 2021-06-14 13:25:50 -04:00
651 changed files with 26021 additions and 13190 deletions

View File

@@ -24,6 +24,9 @@ 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: Build with Gradle
run: ./gradlew qa

View File

@@ -10,6 +10,9 @@
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
<value />
</option>
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />

View File

@@ -11,6 +11,8 @@ apply plugin: 'witness'
apply plugin: 'org.jlleitschuh.gradle.ktlint'
apply from: 'translations.gradle'
apply from: 'witness-verifications.gradle'
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'app.cash.exhaustive'
repositories {
maven {
@@ -55,8 +57,8 @@ protobuf {
}
}
def canonicalVersionCode = 864
def canonicalVersionName = "5.14.3"
def canonicalVersionCode = 879
def canonicalVersionName = "5.17.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -75,6 +77,7 @@ android {
useLibrary 'org.apache.http.legacy'
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = ["-Xallow-result-return-type"]
}
@@ -115,6 +118,8 @@ android {
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\"}"
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
@@ -132,6 +137,10 @@ android {
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
@@ -196,28 +205,34 @@ android {
'proguard/proguard.cfg'
testProguardFiles 'proguard/proguard-automation.pro',
'proguard/proguard.cfg'
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
}
flipper {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Flipper\""
}
release {
minifyEnabled true
proguardFiles = buildTypes.debug.proguardFiles
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
}
perf {
initWith debug
isDefault false
debuggable false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
}
mock {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Mock\""
}
}
@@ -228,6 +243,7 @@ android {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
}
website {
@@ -235,6 +251,7 @@ android {
ext.websiteUpdateUrl = "https://updates.signal.org/android"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
}
internal {
@@ -242,6 +259,16 @@ android {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
}
nightly {
dimension 'distribution'
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"internal\""
}
study {
@@ -251,6 +278,7 @@ android {
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"study\""
}
prod {
@@ -259,6 +287,7 @@ android {
isDefault true
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\""
}
staging {
@@ -281,18 +310,28 @@ android {
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
}
}
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
if (output.baseName.contains('nightly')) {
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
def tag = getCurrentGitTag()
if (tag != null && tag.length() > 0) {
output.versionNameOverride = tag
}
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
}
@@ -305,6 +344,12 @@ android {
variant.setIgnore(true)
} else if (distribution != 'study' && buildType == 'mock') {
variant.setIgnore(true)
} else if (distribution == 'internal' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
variant.setIgnore(true)
} else if (distribution == 'nightly' && environment != 'prod') {
variant.setIgnore(true)
} else if (distribution == 'nightly' && buildType != 'flipper' && buildType != 'perf' && buildType != 'release') {
variant.setIgnore(true)
}
}
@@ -322,6 +367,7 @@ android {
}
dependencies {
implementation 'androidx.core:core-ktx:1.5.0'
implementation 'androidx.fragment:fragment-ktx:1.2.5'
lintChecks project(':lintchecks')
@@ -352,8 +398,9 @@ dependencies {
implementation "androidx.concurrent:concurrent-futures:1.0.0"
implementation "androidx.autofill:autofill:1.0.0"
implementation "androidx.biometric:biometric:1.1.0"
implementation "androidx.sharetarget:sharetarget:1.1.0"
implementation ('com.google.firebase:firebase-messaging:20.2.0') {
implementation ('com.google.firebase:firebase-messaging:22.0.0') {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
@@ -376,16 +423,16 @@ dependencies {
implementation project(':device-transfer')
implementation 'org.signal:zkgroup-android:0.7.0'
implementation 'org.whispersystems:signal-client-android:0.8.0'
implementation 'org.whispersystems:signal-client-android:0.8.3'
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
implementation('com.mobilecoin:android-sdk:1.0.0') {
implementation('com.mobilecoin:android-sdk:1.1.0') {
exclude group: 'com.google.protobuf'
}
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.10.1.1'
implementation 'org.signal:ringrtc-android:2.10.6'
implementation "me.leolin:ShortcutBadger:1.1.22"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -455,11 +502,16 @@ dependencies {
testImplementation 'org.robolectric:shadows-multidex:4.4'
testImplementation 'org.hamcrest:hamcrest:2.2'
testImplementation(testFixtures(project(":libsignal-service")))
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
implementation 'io.reactivex.rxjava3:rxandroid:3.0.0'
implementation 'io.reactivex.rxjava3:rxkotlin:3.0.1'
}
dependencyVerification {
@@ -556,6 +608,26 @@ def getGitHash() {
return stdout.toString().trim()
}
def getCurrentGitTag() {
if (!(new File('.git').exists())) {
return ''
}
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'tag', '--points-at', 'HEAD'
standardOutput = stdout
}
def output = stdout.toString().trim()
if (output != null && output.size() > 0) {
return output.split('\n')[0];
} else {
return null
}
}
tasks.withType(Test) {
testLogging {
events "failed"
@@ -576,3 +648,9 @@ def loadKeystoreProperties(filename) {
return null;
}
}
def getDateSuffix() {
def date = new Date()
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
return formattedDate
}

View File

@@ -198,7 +198,7 @@
<meta-data
android:name="android.service.chooser.chooser_target_service"
android:value=".service.DirectShareService" />
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
</activity>
@@ -308,14 +308,6 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".recipients.ui.managerecipient.ManageRecipientActivity"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -327,7 +319,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".migrations.ApplicationMigrationActivity"
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
@@ -381,6 +373,13 @@
</intent-filter>
</activity>
<activity android:name=".components.settings.conversation.ConversationSettingsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.ConversationSettings"
android:windowSoftInputMode="stateAlwaysHidden">
</activity>
<activity android:name=".wallpaper.ChatWallpaperActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:windowSoftInputMode="stateAlwaysHidden">
@@ -652,13 +651,6 @@
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
</service>
<service android:name=".service.DirectShareService"
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
<intent-filter>
<action android:name="android.service.chooser.ChooserTargetService" />
</intent-filter>
</service>
<service android:name=".service.GenericForegroundService"/>
<service android:name=".gcm.FcmFetchService" />

View File

@@ -512,7 +512,12 @@ final class SignalCameraXModule {
return rotationDegrees;
}
@SuppressLint("UnsafeExperimentalUsageError")
public void invalidateView() {
if (mPreview != null) {
mPreview.setTargetRotation(getDisplaySurfaceRotation()); // Fixes issue #10940 (rotation not updated on phones using "Legacy API")
}
updateViewInfo();
}

View File

@@ -27,6 +27,8 @@ 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;
@@ -37,6 +39,7 @@ import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
@@ -71,6 +74,7 @@ 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.SignalUncaughtExceptionHandler;
@@ -85,6 +89,9 @@ import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
import java.security.Security;
import java.util.concurrent.TimeUnit;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
* Will be called once when the TextSecure process is created.
*
@@ -118,11 +125,15 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("logging", () -> {
initializeLogging();
Log.i(TAG, "onCreate()");
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
.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));
})
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
@@ -163,6 +174,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> DatabaseFactory.getMessageLogDatabase(this).trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -236,7 +248,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void initializeLogging() {
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME);
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME, FeatureFlags.internalUser() ? 15 : 7, ByteUnit.KILOBYTES.toBytes(300));
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());

View File

@@ -70,11 +70,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId);
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
void onVoiceNotePause(@NonNull Uri uri);
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
void onChatSessionRefreshLearnMoreClicked();
void onBadDecryptLearnMoreClicked(@NonNull RecipientId author);

View File

@@ -1,177 +0,0 @@
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.DialogInterface;
import android.os.AsyncTask;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.VerifySpan;
import org.whispersystems.libsignal.SignalProtocolAddress;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.io.IOException;
public class ConfirmIdentityDialog extends AlertDialog {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ConfirmIdentityDialog.class);
private OnClickListener callback;
public ConfirmIdentityDialog(Context context,
MessageRecord messageRecord,
IdentityKeyMismatch mismatch)
{
super(context);
Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
String name = recipient.getDisplayName(context);
String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
SpannableString spannableString = new SpannableString(introduction + " " +
context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
spannableString.setSpan(new VerifySpan(context, mismatch),
introduction.length()+1, spannableString.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
setTitle(name);
setMessage(spannableString);
setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
}
@Override
public void show() {
super.show();
((TextView)this.findViewById(android.R.id.message))
.setMovementMethod(LinkMovementMethod.getInstance());
}
public void setCallback(OnClickListener callback) {
this.callback = callback;
}
private class AcceptListener implements OnClickListener {
private final MessageRecord messageRecord;
private final IdentityKeyMismatch mismatch;
private final RecipientId recipientId;
private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
this.messageRecord = messageRecord;
this.mismatch = mismatch;
this.recipientId = recipientId;
}
@SuppressLint("StaticFieldLeak")
@Override
public void onClick(DialogInterface dialog, int which) {
new AsyncTask<Void, Void, Void>()
{
@Override
protected Void doInBackground(Void... params) {
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
}
processMessageRecord(messageRecord);
return null;
}
private void processMessageRecord(MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
else processIncomingMessageRecord(messageRecord);
}
private void processOutgoingMessageRecord(MessageRecord messageRecord) {
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
if (messageRecord.isMms()) {
mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
if (messageRecord.getRecipient().isPushGroup()) {
MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
} else {
MessageSender.resend(getContext(), messageRecord);
}
} else {
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
MessageSender.resend(getContext(), messageRecord);
}
}
private void processIncomingMessageRecord(MessageRecord messageRecord) {
try {
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
mismatch.getRecipientId(getContext()),
mismatch.getIdentityKey());
boolean legacy = !messageRecord.isContentBundleKeyExchange();
SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
messageRecord.getRecipientDeviceId(),
messageRecord.getDateSent(),
legacy ? Base64.decode(messageRecord.getBody()) : null,
!legacy ? Base64.decode(messageRecord.getBody()) : null,
0,
0,
null);
ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
} catch (IOException e) {
throw new AssertionError(e);
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
if (callback != null) callback.onClick(null, 0);
}
}
private class CancelListener implements OnClickListener {
@Override
public void onClick(DialogInterface dialog, int which) {
if (callback != null) callback.onClick(null, 0);
}
}
}

View File

@@ -20,13 +20,13 @@ import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.appcompat.widget.Toolbar;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
@@ -56,7 +56,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
protected ContactSelectionListFragment contactsFragment;
private ContactFilterToolbar toolbar;
private Toolbar toolbar;
private ContactFilterView contactFilterView;
@Override
protected void onPreCreate() {
@@ -73,6 +74,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
initializeContactFilterView();
initializeToolbar();
initializeResources();
initializeSearch();
@@ -84,16 +86,23 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
dynamicTheme.onResume(this);
}
protected ContactFilterToolbar getToolbar() {
protected Toolbar getToolbar() {
return toolbar;
}
protected ContactFilterView getContactFilterView() {
return contactFilterView;
}
private void initializeContactFilterView() {
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
}
private void initializeToolbar() {
this.toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
getSupportActionBar().setDisplayShowTitleEnabled(false);
getSupportActionBar().setIcon(null);
getSupportActionBar().setLogo(null);
}
@@ -104,7 +113,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
private void initializeSearch() {
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
contactFilterView.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
}
@Override
@@ -155,7 +164,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
ContactSelectionActivity activity = this.activity.get();
if (activity != null && !activity.isFinishing()) {
activity.toolbar.clear();
activity.contactFilterView.clear();
activity.contactsFragment.resetQueryFilter();
}
}

View File

@@ -57,13 +57,14 @@ import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
import org.thoughtcrime.securesms.components.emoji.WarningTextView;
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactChip;
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.groups.SelectionLimits;
@@ -74,7 +75,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -131,9 +131,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private ChipGroup chipGroup;
private HorizontalScrollView chipGroupScrollContainer;
private WarningTextView groupLimit;
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
private View shadowView;
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
@Nullable private FixedViewsAdapter headerAdapter;
@@ -233,9 +234,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
showContactsProgress = view.findViewById(R.id.progress);
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
groupLimit = view.findViewById(R.id.group_limit);
constraintLayout = view.findViewById(R.id.container);
shadowView = view.findViewById(R.id.toolbar_shadow);
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
@@ -272,8 +276,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
currentSelection = getCurrentSelection();
updateGroupLimit(getChipCount());
return view;
}
@@ -281,13 +283,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
return getArguments() != null ? getArguments() : new Bundle();
}
private void updateGroupLimit(int chipCount) {
int members = currentSelection.size() + chipCount;
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
groupLimit.setWarning(selectionWarningLimitExceeded());
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -309,6 +304,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
return cursorRecyclerViewAdapter.getSelectedContactsCount();
}
public int getTotalMemberCount() {
if (cursorRecyclerViewAdapter == null) {
return 0;
}
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
}
private Set<RecipientId> getCurrentSelection() {
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
if (currentSelection == null) {
@@ -349,8 +352,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
concatenateAdapter.addAdapter(footerAdapter);
}
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
recyclerView.setAdapter(concatenateAdapter);
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
@@ -361,6 +364,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
});
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private boolean hideLetterHeaders() {
return hasQueryFilter() || shouldDisplayRecents();
}
private View createInviteActionView(@NonNull ListCallback listCallback) {
@@ -429,7 +440,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
FragmentActivity activity = requireActivity();
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
boolean displayRecents = shouldDisplayRecents();
if (cursorFactoryProvider != null) {
return cursorFactoryProvider.get().create();
@@ -475,6 +486,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
fastScroller.setVisibility(View.GONE);
}
private boolean shouldDisplayRecents() {
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
}
@SuppressLint("StaticFieldLeak")
private void handleContactPermissionGranted() {
final Context context = requireContext();
@@ -606,12 +621,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (isMulti) {
addChipForSelectedContact(selectedContact);
}
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
removeChipForContact(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onSelectionChanged();
}
}
private void removeChipForContact(@NonNull SelectedContact contact) {
@@ -622,8 +644,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
}
updateGroupLimit(getChipCount());
if (getChipCount() == 0) {
setChipGroupVisibility(ConstraintSet.GONE);
}
@@ -673,7 +693,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
private void addChip(@NonNull ContactChip chip) {
chipGroup.addView(chip);
updateGroupLimit(getChipCount());
if (selectionWarningLimitReachedExactly()) {
if (onSelectionLimitReachedListener != null) {
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
@@ -726,6 +745,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
/** @return True if the contact is allowed to be selected, otherwise false. */
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
void onContactDeselected(Optional<RecipientId> recipientId, String number);
void onSelectionChanged();
}
public interface OnSelectionLimitReachedListener {

View File

@@ -7,8 +7,6 @@ import android.graphics.PorterDuff;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.text.Editable;
import android.text.TextWatcher;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
@@ -24,8 +22,8 @@ import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -100,7 +98,8 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
View shareButton = findViewById(R.id.share_button);
Button smsButton = findViewById(R.id.sms_button);
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
Toolbar smsToolbar = findViewById(R.id.sms_send_frame_toolbar);
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
inviteText = findViewById(R.id.invite_text);
smsSendFrame = findViewById(R.id.sms_send_frame);
@@ -121,7 +120,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
smsSendButton.setOnClickListener(new SmsSendClickListener());
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
smsToolbar.setNavigationIcon(R.drawable.ic_search_conversation_24);
if (Util.isDefaultSmsProvider(this)) {
shareButton.setOnClickListener(new ShareClickListener());
@@ -150,6 +149,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
}
@Override
public void onSelectionChanged() {
}
private void sendSmsInvites() {
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,

View File

@@ -9,6 +9,8 @@ import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -17,13 +19,15 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public class MainActivity extends PassphraseRequiredActivity {
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final MainNavigator navigator = new MainNavigator(this);
private VoiceNoteMediaController mediaController;
public static @NonNull Intent clearTop(@NonNull Context context) {
Intent intent = new Intent(context, MainActivity.class);
@@ -40,6 +44,7 @@ public class MainActivity extends PassphraseRequiredActivity {
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.main_activity);
mediaController = new VoiceNoteMediaController(this);
navigator.onCreate(savedInstanceState);
handleGroupLinkInIntent(getIntent());
@@ -109,4 +114,9 @@ public class MainActivity extends PassphraseRequiredActivity {
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
}
}
@Override
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
return mediaController;
}
}

View File

@@ -7,6 +7,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import java.util.concurrent.TimeUnit;
public class MuteDialog extends AlertDialog {
@@ -29,7 +31,7 @@ public class MuteDialog extends AlertDialog {
}
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
AlertDialog.Builder builder = new AlertDialog.Builder(context);
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
builder.setTitle(R.string.MuteDialog_mute_notifications);
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
@Override

View File

@@ -56,6 +56,7 @@ public class NewConversationActivity extends ContactSelectionActivity
super.onCreate(bundle, ready);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
}
@Override
@@ -96,6 +97,10 @@ public class NewConversationActivity extends ContactSelectionActivity
return true;
}
@Override
public void onSelectionChanged() {
}
private void launch(Recipient recipient) {
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)

View File

@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
setResult(RESULT_OK, resultIntent);
finish();
}
@Override
public void onSelectionChanged() {
}
}

View File

@@ -29,7 +29,6 @@ import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.PorterDuff;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Vibrator;
@@ -63,9 +62,8 @@ import androidx.fragment.app.FragmentTransaction;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -87,16 +85,13 @@ import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.libsignal.fingerprint.Fingerprint;
import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
import org.whispersystems.signalservice.api.SignalSessionLock;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.util.Locale;
@@ -615,7 +610,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
final RecipientId recipientId = recipient.getId();
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (isChecked) {
Log.i(TAG, "Saving identity: " + recipientId);
DatabaseFactory.getIdentityDatabase(getActivity())

View File

@@ -0,0 +1,97 @@
package org.thoughtcrime.securesms.animation.transitions
import android.animation.Animator
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.animation.TypeEvaluator
import android.content.Context
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.Interpolator
import androidx.annotation.RequiresApi
import org.thoughtcrime.securesms.components.AvatarImageView
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
private const val WIDTH = "signal.circleavatartransition.width"
private const val HEIGHT = "signal.circleavatartransition.height"
/**
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
*/
@RequiresApi(21)
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
override fun captureEndValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
private fun captureValues(transitionValues: TransitionValues) {
val view: View = transitionValues.view
if (view is AvatarImageView) {
val topLeft = intArrayOf(0, 0)
view.getLocationOnScreen(topLeft)
transitionValues.values[POSITION_ON_SCREEN] = topLeft
transitionValues.values[WIDTH] = view.measuredWidth
transitionValues.values[HEIGHT] = view.measuredHeight
}
}
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (startValues == null || endValues == null) {
return null
}
val view: View = endValues.view
if (view !is AvatarImageView || view.transitionName != "avatar") {
return null
}
val startCoords: IntArray = startValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
val endCoords: IntArray = endValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
val startWidth: Int = startValues.values[WIDTH] as? Int ?: view.measuredWidth
val endWidth: Int = endValues.values[WIDTH] as? Int ?: view.measuredWidth
val startHeight: Int = startValues.values[HEIGHT] as? Int ?: view.measuredHeight
val endHeight: Int = endValues.values[HEIGHT] as? Int ?: view.measuredHeight
val startHeightOffset = (endHeight - startHeight) / 2f
val startWidthOffset = (endWidth - startWidth) / 2f
val translateXHolder = PropertyValuesHolder.ofFloat("translationX", startCoords[0] - endCoords[0] - startWidthOffset, 0f).apply {
setEvaluator(FloatInterpolatorEvaluator(DecelerateInterpolator()))
}
val translateYHolder = PropertyValuesHolder.ofFloat("translationY", startCoords[1] - endCoords[1] - startHeightOffset, 0f).apply {
setEvaluator(FloatInterpolatorEvaluator(AccelerateInterpolator()))
}
val widthRatio = startWidth.toFloat() / endWidth
val scaleXHolder = PropertyValuesHolder.ofFloat("scaleX", widthRatio, 1f)
val heightRatio = startHeight.toFloat() / endHeight
val scaleYHolder = PropertyValuesHolder.ofFloat("scaleY", heightRatio, 1f)
return ObjectAnimator.ofPropertyValuesHolder(view, translateXHolder, translateYHolder, scaleXHolder, scaleYHolder)
}
private class FloatInterpolatorEvaluator(
private val interpolator: Interpolator
) : TypeEvaluator<Float> {
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
val interpolatedFraction = interpolator.getInterpolation(fraction)
val delta = endValue - startValue
return delta * interpolatedFraction + startValue
}
}
}

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.animation.transitions
import android.animation.Animator
import android.animation.ObjectAnimator
import android.animation.RectEvaluator
import android.content.Context
import android.graphics.Rect
import android.transition.Transition
import android.transition.TransitionValues
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.animation.addListener
import androidx.fragment.app.FragmentContainerView
private const val BOUNDS = "signal.wipedowntransition.bottom"
/**
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
*/
@RequiresApi(21)
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
override fun captureStartValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
override fun captureEndValues(transitionValues: TransitionValues) {
captureValues(transitionValues)
}
private fun captureValues(transitionValues: TransitionValues) {
val view: View = transitionValues.view
if (view is ViewGroup) {
val rect = Rect()
view.getLocalVisibleRect(rect)
transitionValues.values[BOUNDS] = rect
}
}
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
if (startValues == null || endValues == null) {
return null
}
val view: View = endValues.view
if (view !is FragmentContainerView) {
return null
}
val startBottom: Rect = startValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
val endBottom: Rect = endValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom).apply {
addListener(
onEnd = {
view.clipBounds = null
}
)
}
}
}

View File

@@ -11,11 +11,11 @@ import androidx.annotation.NonNull;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.Pair;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
@@ -51,7 +51,7 @@ public class AudioRecorder {
captureUri = BlobProvider.getInstance()
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
.withMimeType(MediaUtil.AUDIO_AAC)
.createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
audioCodec = new AudioCodec();
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
@@ -61,10 +61,10 @@ public class AudioRecorder {
});
}
public @NonNull ListenableFuture<Pair<Uri, Long>> stopRecording() {
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
Log.i(TAG, "stopRecording()");
final SettableFuture<Pair<Uri, Long>> future = new SettableFuture<>();
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
executor.execute(() -> {
if (audioCodec == null) {
@@ -76,7 +76,7 @@ public class AudioRecorder {
try {
long size = MediaUtil.getMediaSize(context, captureUri);
sendToFuture(future, new Pair<>(captureUri, size));
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
} catch (IOException ioe) {
Log.w(TAG, ioe);
sendToFuture(future, ioe);

View File

@@ -65,12 +65,6 @@ public final class AudioWaveForm {
return;
}
if (!(attachment instanceof DatabaseAttachment)) {
Log.i(TAG, "Not yet in database");
ThreadUtil.runOnMain(onFailure);
return;
}
String cacheKey = uri.toString();
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
if (cached != null) {
@@ -104,26 +98,46 @@ public final class AudioWaveForm {
}
}
try {
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
if (attachment instanceof DatabaseAttachment) {
try {
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (Throwable e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (Throwable e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
} else {
try {
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
long startTime = System.currentTimeMillis();
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (IOException e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
ThreadUtil.runOnMain(onFailure);
}
}
});
}

View File

@@ -5,7 +5,6 @@ import android.content.Intent;
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
import android.widget.ViewSwitcher;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
@@ -18,7 +17,7 @@ import com.google.android.material.snackbar.Snackbar;
import org.thoughtcrime.securesms.ContactSelectionListFragment;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -47,27 +46,26 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
ViewSwitcher viewSwitcher = findViewById(R.id.toolbar_switcher);
Toolbar toolbar = findViewById(R.id.toolbar);
ContactFilterToolbar contactFilterToolbar = findViewById(R.id.filter_toolbar);
View container = findViewById(R.id.fragment_container);
Toolbar toolbar = findViewById(R.id.toolbar);
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
View container = findViewById(R.id.fragment_container);
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
contactFilterToolbar.setNavigationOnClickListener(unused -> onBackPressed());
contactFilterToolbar.setOnFilterChangedListener(query -> {
contactFilterView.setOnFilterChangedListener(query -> {
Fragment fragment = getSupportFragmentManager().findFragmentByTag(CONTACT_SELECTION_FRAGMENT);
if (fragment != null) {
((ContactSelectionListFragment) fragment).setQueryFilter(query);
}
});
contactFilterToolbar.setHint(R.string.BlockedUsersActivity__add_blocked_user);
contactFilterView.setHint(R.string.BlockedUsersActivity__add_blocked_user);
//noinspection CodeBlock2Expr
getSupportFragmentManager().addOnBackStackChangedListener(() -> {
viewSwitcher.setDisplayedChild(getSupportFragmentManager().getBackStackEntryCount());
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
contactFilterToolbar.focusAndShowKeyboard();
contactFilterView.setVisibility(View.VISIBLE);
contactFilterView.focusAndShowKeyboard();
} else {
contactFilterView.setVisibility(View.GONE);
}
});
@@ -119,6 +117,10 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
}
@Override
public void onSelectionChanged() {
}
@Override
public void handleAddUserToBlockedList() {
ContactSelectionListFragment fragment = new ContactSelectionListFragment();
@@ -164,6 +166,6 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
throw new IllegalArgumentException("Unsupported event type " + event);
}
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).show();
Snackbar.make(view, getString(messageResId, displayName), Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show();
}
}

View File

@@ -45,17 +45,21 @@ public final class AudioView extends FrameLayout {
private static final String TAG = Log.tag(AudioView.class);
private static final int MODE_NORMAL = 0;
private static final int MODE_SMALL = 1;
private static final int MODE_DRAFT = 2;
private static final int FORWARDS = 1;
private static final int REVERSE = -1;
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final ImageView downloadButton;
@NonNull private final ProgressWheel circleProgress;
@NonNull private final SeekBar seekBar;
private final boolean smallView;
private final boolean autoRewind;
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final ImageView downloadButton;
@Nullable private final ProgressWheel circleProgress;
@NonNull private final SeekBar seekBar;
private final boolean smallView;
private final boolean autoRewind;
@Nullable private final TextView duration;
@@ -87,10 +91,23 @@ public final class AudioView extends FrameLayout {
try {
typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AudioView, 0, 0);
smallView = typedArray.getBoolean(R.styleable.AudioView_small, false);
int mode = typedArray.getInteger(R.styleable.AudioView_audioView_mode, MODE_NORMAL);
smallView = mode == MODE_SMALL;
autoRewind = typedArray.getBoolean(R.styleable.AudioView_autoRewind, false);
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
switch (mode) {
case MODE_NORMAL:
inflate(context, R.layout.audio_view, this);
break;
case MODE_SMALL:
inflate(context, R.layout.audio_view_small, this);
break;
case MODE_DRAFT:
inflate(context, R.layout.audio_view_draft, this);
break;
default:
throw new IllegalStateException("Unsupported mode: " + mode);
}
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
@@ -110,7 +127,7 @@ public final class AudioView extends FrameLayout {
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE);
progressAndPlay.getBackground().setColorFilter(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK), PorterDuff.Mode.SRC_IN);
setProgressAndPlayBackgroundTint(typedArray.getColor(R.styleable.AudioView_progressAndPlayTint, Color.BLACK));
} finally {
if (typedArray != null) {
typedArray.recycle();
@@ -130,6 +147,10 @@ public final class AudioView extends FrameLayout {
EventBus.getDefault().unregister(this);
}
public void setProgressAndPlayBackgroundTint(@ColorInt int color) {
progressAndPlay.getBackground().setColorFilter(color, PorterDuff.Mode.SRC_IN);
}
public Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
return playbackStateObserver;
}
@@ -158,16 +179,20 @@ public final class AudioView extends FrameLayout {
controlToggle.displayQuick(downloadButton);
seekBar.setEnabled(false);
downloadButton.setOnClickListener(new DownloadClickedListener(audio));
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
if (circleProgress != null) {
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
circleProgress.setVisibility(View.GONE);
}
} else if (showControls && audio.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
circleProgress.setVisibility(View.VISIBLE);
circleProgress.spin();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
circleProgress.spin();
}
} else {
seekBar.setEnabled(true);
if (circleProgress.isSpinning()) circleProgress.stopSpinning();
if (circleProgress != null && circleProgress.isSpinning()) circleProgress.stopSpinning();
showPlayButton();
}
@@ -211,10 +236,11 @@ public final class AudioView extends FrameLayout {
private void onPlaybackState(@NonNull VoiceNotePlaybackState voiceNotePlaybackState) {
onDuration(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getTrackDuration());
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isAutoReset());
onProgress(voiceNotePlaybackState.getUri(),
(double) voiceNotePlaybackState.getPlayheadPositionMillis() / voiceNotePlaybackState.getTrackDuration(),
voiceNotePlaybackState.getPlayheadPositionMillis());
onSpeedChanged(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.getSpeed());
onStart(voiceNotePlaybackState.getUri(), voiceNotePlaybackState.isPlaying(), voiceNotePlaybackState.isAutoReset());
}
private void onDuration(@NonNull Uri uri, long durationMillis) {
@@ -223,8 +249,8 @@ public final class AudioView extends FrameLayout {
}
}
private void onStart(@NonNull Uri uri, boolean autoReset) {
if (!isTarget(uri)) {
private void onStart(@NonNull Uri uri, boolean statePlaying, boolean autoReset) {
if (!isTarget(uri) || !statePlaying) {
if (hasAudioUri()) {
onStop(audioSlide.getUri(), autoReset);
}
@@ -274,6 +300,12 @@ public final class AudioView extends FrameLayout {
}
}
private void onSpeedChanged(@NonNull Uri uri, float speed) {
if (callbacks != null) {
callbacks.onSpeedChanged(speed, isTarget(uri));
}
}
private boolean isTarget(@NonNull Uri uri) {
return hasAudioUri() && Objects.equals(uri, audioSlide.getUri());
}
@@ -318,7 +350,7 @@ public final class AudioView extends FrameLayout {
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
if (smallView) {
if (smallView && circleProgress != null) {
circleProgress.setInstantProgress(seekBar.getProgress() == 0 ? 1 : progress);
}
}
@@ -329,7 +361,10 @@ public final class AudioView extends FrameLayout {
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
this.circleProgress.setBarColor(foregroundTint);
if (circleProgress != null) {
this.circleProgress.setBarColor(foregroundTint);
}
if (this.duration != null) {
this.duration.setTextColor(foregroundTint);
@@ -372,11 +407,14 @@ public final class AudioView extends FrameLayout {
}
private void showPlayButton() {
if (!smallView) {
circleProgress.setVisibility(GONE);
} else if (seekBar.getProgress() == 0) {
circleProgress.setInstantProgress(1);
if (circleProgress != null) {
if (!smallView) {
circleProgress.setVisibility(GONE);
} else if (seekBar.getProgress() == 0) {
circleProgress.setInstantProgress(1);
}
}
playPauseButton.setVisibility(VISIBLE);
controlToggle.displayQuick(progressAndPlay);
}
@@ -451,6 +489,8 @@ public final class AudioView extends FrameLayout {
if (callbacks != null) {
if (wasPlaying) {
callbacks.onSeekTo(audioSlide.getUri(), getProgress());
} else {
callbacks.onProgressUpdated(durationMillis, Math.round(durationMillis * getProgress()));
}
}
}
@@ -465,7 +505,7 @@ public final class AudioView extends FrameLayout {
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
if (audioSlide != null && event.attachment.equals(audioSlide.asAttachment())) {
if (audioSlide != null && circleProgress != null && event.attachment.equals(audioSlide.asAttachment())) {
circleProgress.setInstantProgress(((float) event.progress) / event.total);
}
}
@@ -475,6 +515,7 @@ public final class AudioView extends FrameLayout {
void onPause(@NonNull Uri audioUri);
void onSeekTo(@NonNull Uri audioUri, double progress);
void onStopAndReset(@NonNull Uri audioUri);
void onSpeedChanged(float speed, boolean isPlaying);
void onProgressUpdated(long durationMillis, long playheadMillis);
}
}

View File

@@ -6,12 +6,15 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import androidx.annotation.DrawableRes;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.fragment.app.FragmentActivity;
@@ -20,21 +23,23 @@ import com.bumptech.glide.load.Transformation;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.bitmap.CircleCrop;
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
import com.bumptech.glide.request.target.SimpleTarget;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequest;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.ui.managerecipient.ManageRecipientActivity;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.BlurTransformation;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -74,6 +79,7 @@ public final class AvatarImageView extends AppCompatImageView {
private Recipient.FallbackPhotoProvider fallbackPhotoProvider;
private boolean blurred;
private ChatColors chatColors;
private FixedSizeTarget fixedSizeTarget;
private @Nullable RecipientContactPhoto recipientContactPhoto;
private @NonNull Drawable unknownRecipientDrawable;
@@ -93,8 +99,8 @@ public final class AvatarImageView extends AppCompatImageView {
if (attrs != null) {
TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AvatarImageView, 0, 0);
inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
inverted = typedArray.getBoolean(R.styleable.AvatarImageView_inverted, false);
size = typedArray.getInt(R.styleable.AvatarImageView_fallbackImageSize, SIZE_LARGE);
typedArray.recycle();
}
@@ -105,6 +111,11 @@ public final class AvatarImageView extends AppCompatImageView {
chatColors = null;
}
@Override
public void setClipBounds(Rect clipBounds) {
super.setClipBounds(clipBounds);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
@@ -148,6 +159,10 @@ public final class AvatarImageView extends AppCompatImageView {
}
}
public AvatarOptions.Builder buildOptions() {
return new AvatarOptions.Builder(this);
}
/**
* Shows self as the note to self icon.
*/
@@ -167,11 +182,22 @@ public final class AvatarImageView extends AppCompatImageView {
}
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, boolean quickContactEnabled, boolean useSelfProfileAvatar) {
setAvatar(requestManager, recipient, new AvatarOptions.Builder(this)
.withUseSelfProfileAvatar(useSelfProfileAvatar)
.withQuickContactEnabled(quickContactEnabled)
.build());
}
private void setAvatar(@Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
setAvatar(GlideApp.with(this), recipient, avatarOptions);
}
private void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @NonNull AvatarOptions avatarOptions) {
if (recipient != null) {
RecipientContactPhoto photo = (recipient.isSelf() && useSelfProfileAvatar) ? new RecipientContactPhoto(recipient,
new ProfileContactPhoto(Recipient.self(),
Recipient.self().getProfileAvatar()))
: new RecipientContactPhoto(recipient);
RecipientContactPhoto photo = (recipient.isSelf() && avatarOptions.useSelfProfileAvatar) ? new RecipientContactPhoto(recipient,
new ProfileContactPhoto(Recipient.self(),
Recipient.self().getProfileAvatar()))
: new RecipientContactPhoto(recipient);
boolean shouldBlur = recipient.shouldBlurAvatar();
ChatColors chatColors = recipient.getChatColors();
@@ -184,6 +210,10 @@ public final class AvatarImageView extends AppCompatImageView {
Drawable fallbackContactPhotoDrawable = size == SIZE_SMALL ? photo.recipient.getSmallFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider)
: photo.recipient.getFallbackContactPhotoDrawable(getContext(), inverted, fallbackPhotoProvider);
if (fixedSizeTarget != null) {
requestManager.clear(fixedSizeTarget);
}
if (photo.contactPhoto != null) {
List<Transformation<Bitmap>> transforms = new ArrayList<>();
@@ -193,19 +223,26 @@ public final class AvatarImageView extends AppCompatImageView {
transforms.add(new CircleCrop());
blurred = shouldBlur;
requestManager.load(photo.contactPhoto)
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms))
.into(this);
GlideRequest<Drawable> request = requestManager.load(photo.contactPhoto)
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.downsample(DownsampleStrategy.CENTER_INSIDE)
.transform(new MultiTransformation<>(transforms));
if (avatarOptions.fixedSize > 0) {
fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize);
request.into(fixedSizeTarget);
} else {
request.into(this);
}
} else {
setImageDrawable(fallbackContactPhotoDrawable);
}
}
setAvatarClickHandler(recipient, quickContactEnabled);
setAvatarClickHandler(recipient, avatarOptions.quickContactEnabled);
} else {
recipientContactPhoto = null;
requestManager.clear(this);
@@ -225,15 +262,15 @@ public final class AvatarImageView extends AppCompatImageView {
super.setOnClickListener(v -> {
Context context = getContext();
if (recipient.isPushGroup()) {
context.startActivity(ManageGroupActivity.newIntent(context, recipient.requireGroupId().requirePush()),
ManageGroupActivity.createTransitionBundle(context, this));
context.startActivity(ConversationSettingsActivity.forGroup(context, recipient.requireGroupId().requirePush()),
ConversationSettingsActivity.createTransitionBundle(context, this));
} else {
if (context instanceof FragmentActivity) {
RecipientBottomSheetDialogFragment.create(recipient.getId(), null)
.show(((FragmentActivity) context).getSupportFragmentManager(), "BOTTOM");
} else {
context.startActivity(ManageRecipientActivity.newIntent(context, recipient.getId()),
ManageRecipientActivity.createTransitionBundle(context, this));
context.startActivity(ConversationSettingsActivity.forRecipient(context, recipient.getId()),
ConversationSettingsActivity.createTransitionBundle(context, this));
}
}
});
@@ -294,4 +331,65 @@ public final class AvatarImageView extends AppCompatImageView {
Objects.equals(other.contactPhoto, contactPhoto);
}
}
private final class FixedSizeTarget extends SimpleTarget<Drawable> {
FixedSizeTarget(int size) {
super(size, size);
}
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
setImageDrawable(resource);
}
}
public static final class AvatarOptions {
private final boolean quickContactEnabled;
private final boolean useSelfProfileAvatar;
private final int fixedSize;
private AvatarOptions(@NonNull Builder builder) {
this.quickContactEnabled = builder.quickContactEnabled;
this.useSelfProfileAvatar = builder.useSelfProfileAvatar;
this.fixedSize = builder.fixedSize;
}
public static final class Builder {
private final AvatarImageView avatarImageView;
private boolean quickContactEnabled = false;
private boolean useSelfProfileAvatar = false;
private int fixedSize = -1;
private Builder(@NonNull AvatarImageView avatarImageView) {
this.avatarImageView = avatarImageView;
}
public @NonNull Builder withQuickContactEnabled(boolean quickContactEnabled) {
this.quickContactEnabled = quickContactEnabled;
return this;
}
public @NonNull Builder withUseSelfProfileAvatar(boolean useSelfProfileAvatar) {
this.useSelfProfileAvatar = useSelfProfileAvatar;
return this;
}
public @NonNull Builder withFixedSize(@Px @IntRange(from = 1) int fixedSize) {
this.fixedSize = fixedSize;
return this;
}
public AvatarOptions build() {
return new AvatarOptions(this);
}
public void load(@Nullable Recipient recipient) {
avatarImageView.setAvatar(recipient, build());
}
}
}
}

View File

@@ -10,6 +10,7 @@ import android.util.AttributeSet;
import android.view.TouchDelegate;
import android.view.View;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
@@ -20,9 +21,8 @@ import androidx.core.widget.TextViewCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
public final class ContactFilterToolbar extends DarkOverflowToolbar {
public final class ContactFilterView extends FrameLayout {
private OnFilterChangedListener listener;
private final EditText searchText;
@@ -32,17 +32,17 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
private final ImageView clearToggle;
private final LinearLayout toggleContainer;
public ContactFilterToolbar(Context context) {
public ContactFilterView(Context context) {
this(context, null);
}
public ContactFilterToolbar(Context context, AttributeSet attrs) {
public ContactFilterView(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.toolbarStyle);
}
public ContactFilterToolbar(Context context, AttributeSet attrs, int defStyleAttr) {
public ContactFilterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
inflate(context, R.layout.contact_filter_toolbar, this);
inflate(context, R.layout.contact_filter_view, this);
this.searchText = findViewById(R.id.search_view);
this.toggle = findViewById(R.id.button_toggle);
@@ -99,8 +99,6 @@ public final class ContactFilterToolbar extends DarkOverflowToolbar {
}
});
setLogo(null);
setContentInsetStartWithNavigation(0);
expandTapArea(toggleContainer, dialpadToggle);
applyAttributes(searchText, context, attrs, defStyleAttr);
searchText.requestFocus();

View File

@@ -4,6 +4,9 @@ import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.tabs.TabLayout;
import java.util.List;
@@ -15,6 +18,8 @@ public class ControllableTabLayout extends TabLayout {
private List<View> touchables;
private NewTabListener newTabListener;
public ControllableTabLayout(Context context) {
super(context);
}
@@ -39,4 +44,28 @@ public class ControllableTabLayout extends TabLayout {
super.setEnabled(enabled);
}
public void setNewTabListener(@Nullable NewTabListener newTabListener) {
this.newTabListener = newTabListener;
}
@Override
public @NonNull Tab newTab() {
Tab tab = super.newTab();
if (newTabListener != null) {
newTabListener.onNewTab(tab);
}
return tab;
}
/**
* Allows implementor to modify tabs when they are created, before they are added to the tab layout.
* This is useful for loading custom views, to ensure that time is not spent inflating these views
* as the user is switching between pages.
*/
public interface NewTabListener {
void onNewTab(@NonNull Tab tab);
}
}

View File

@@ -1,28 +1,33 @@
package org.thoughtcrime.securesms.components;
import android.Manifest;
import android.animation.Animator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import com.airbnb.lottie.LottieAnimationView;
import com.airbnb.lottie.LottieProperty;
import com.airbnb.lottie.model.KeyPath;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.ApplicationContext;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
@@ -30,7 +35,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
@@ -40,17 +44,24 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
public class ConversationItemFooter extends LinearLayout {
public class ConversationItemFooter extends ConstraintLayout {
private TextView dateView;
private TextView simView;
private ExpirationTimerView timerView;
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
private boolean onlyShowSendingStatus;
private View audioSpace;
private TextView audioDuration;
private LottieAnimationView revealDot;
private TextView dateView;
private TextView simView;
private ExpirationTimerView timerView;
private ImageView insecureIndicatorView;
private DeliveryStatusView deliveryStatusView;
private boolean onlyShowSendingStatus;
private TextView audioDuration;
private LottieAnimationView revealDot;
private PlaybackSpeedToggleTextView playbackSpeedToggleTextView;
private boolean isOutgoing;
private boolean hasShrunkDate;
private OnTouchDelegateChangedListener onTouchDelegateChangedListener;
private final Rect speedToggleHitRect = new Rect();
private final int touchTargetSize = ViewUtil.dpToPx(48);
public ConversationItemFooter(Context context) {
super(context);
@@ -68,24 +79,55 @@ public class ConversationItemFooter extends LinearLayout {
}
private void init(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.conversation_item_footer, this);
dateView = findViewById(R.id.footer_date);
simView = findViewById(R.id.footer_sim_info);
timerView = findViewById(R.id.footer_expiration_timer);
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
audioDuration = findViewById(R.id.footer_audio_duration);
audioSpace = findViewById(R.id.footer_audio_duration_space);
revealDot = findViewById(R.id.footer_revealed_dot);
final TypedArray typedArray;
if (attrs != null) {
TypedArray typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
typedArray = getContext().getTheme().obtainStyledAttributes(attrs, R.styleable.ConversationItemFooter, 0, 0);
} else {
typedArray = null;
}
final @LayoutRes int contentId;
if (typedArray != null) {
int mode = typedArray.getInt(R.styleable.ConversationItemFooter_footer_mode, 0);
isOutgoing = mode == 0;
if (isOutgoing) {
contentId = R.layout.conversation_item_footer_outgoing;
} else {
contentId = R.layout.conversation_item_footer_incoming;
}
} else {
contentId = R.layout.conversation_item_footer_outgoing;
isOutgoing = true;
}
inflate(getContext(), contentId, this);
dateView = findViewById(R.id.footer_date);
simView = findViewById(R.id.footer_sim_info);
timerView = findViewById(R.id.footer_expiration_timer);
insecureIndicatorView = findViewById(R.id.footer_insecure_indicator);
deliveryStatusView = findViewById(R.id.footer_delivery_status);
audioDuration = findViewById(R.id.footer_audio_duration);
revealDot = findViewById(R.id.footer_revealed_dot);
playbackSpeedToggleTextView = findViewById(R.id.footer_audio_playback_speed_toggle);
if (typedArray != null) {
setTextColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_text_color, getResources().getColor(R.color.core_white)));
setIconColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_icon_color, getResources().getColor(R.color.core_white)));
setRevealDotColor(typedArray.getInt(R.styleable.ConversationItemFooter_footer_reveal_dot_color, getResources().getColor(R.color.core_white)));
typedArray.recycle();
}
dateView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (oldLeft != left || oldRight != right) {
notifyTouchDelegateChanged(getPlaybackSpeedToggleTouchDelegateRect(), playbackSpeedToggleTextView);
}
});
}
public void setOnTouchDelegateChangedListener(@Nullable OnTouchDelegateChangedListener onTouchDelegateChangedListener) {
this.onTouchDelegateChangedListener = onTouchDelegateChangedListener;
}
@Override
@@ -108,6 +150,20 @@ public class ConversationItemFooter extends LinearLayout {
audioDuration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
public void setPlaybackSpeedListener(@Nullable PlaybackSpeedToggleTextView.PlaybackSpeedListener playbackSpeedListener) {
playbackSpeedToggleTextView.setPlaybackSpeedListener(playbackSpeedListener);
}
public void setAudioPlaybackSpeed(float playbackSpeed, boolean isPlaying) {
if (isPlaying) {
showPlaybackSpeedToggle();
} else {
hidePlaybackSpeedToggle();
}
playbackSpeedToggleTextView.setCurrentSpeed(playbackSpeed);
}
public void setTextColor(int color) {
dateView.setTextColor(color);
simView.setTextColor(color);
@@ -155,6 +211,84 @@ public class ConversationItemFooter extends LinearLayout {
}
}
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
if (onTouchDelegateChangedListener != null) {
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);
}
}
private void showPlaybackSpeedToggle() {
if (hasShrunkDate) {
return;
}
hasShrunkDate = true;
playbackSpeedToggleTextView.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(150L)
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
playbackSpeedToggleTextView.setClickable(true);
}
});
if (isOutgoing) {
dateView.setMaxWidth(ViewUtil.dpToPx(28));
} else {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(this);
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, ViewUtil.dpToPx(40));
constraintSet.applyTo(this);
}
}
private void hidePlaybackSpeedToggle() {
if (!hasShrunkDate) {
return;
}
hasShrunkDate = false;
playbackSpeedToggleTextView.animate()
.alpha(0f)
.scaleX(0.5f)
.scaleY(0.5f)
.setDuration(150L).setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
playbackSpeedToggleTextView.setClickable(false);
playbackSpeedToggleTextView.clearRequestedSpeed();
}
});
if (isOutgoing) {
dateView.setMaxWidth(Integer.MAX_VALUE);
} else {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(this);
constraintSet.constrainMaxWidth(R.id.date_and_expiry_wrapper, -1);
constraintSet.applyTo(this);
}
}
private @NonNull Rect getPlaybackSpeedToggleTouchDelegateRect() {
playbackSpeedToggleTextView.getHitRect(speedToggleHitRect);
int widthOffset = (touchTargetSize - speedToggleHitRect.width()) / 2;
int heightOffset = (touchTargetSize - speedToggleHitRect.height()) / 2;
speedToggleHitRect.top -= heightOffset;
speedToggleHitRect.left -= widthOffset;
speedToggleHitRect.right += widthOffset;
speedToggleHitRect.bottom += heightOffset;
return speedToggleHitRect;
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
dateView.forceLayout();
if (messageRecord.isFailed()) {
@@ -189,7 +323,7 @@ public class ConversationItemFooter extends LinearLayout {
simView.setText(getContext().getString(R.string.ConversationItem_from_s, subscriptionInfo.get().getDisplayName()));
simView.setVisibility(View.VISIBLE);
} else if (subscriptionInfo.isPresent()) {
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
simView.setText(getContext().getString(R.string.ConversationItem_to_s, subscriptionInfo.get().getDisplayName()));
simView.setVisibility(View.VISIBLE);
} else {
simView.setVisibility(View.GONE);
@@ -218,7 +352,7 @@ public class ConversationItemFooter extends LinearLayout {
boolean mms = messageRecord.isMms();
if (mms) DatabaseFactory.getMmsDatabase(getContext()).markExpireStarted(id);
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
else DatabaseFactory.getSmsDatabase(getContext()).markExpireStarted(id);
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
});
@@ -245,7 +379,7 @@ public class ConversationItemFooter extends LinearLayout {
deliveryStatusView.setNone();
}
} else {
if (!messageRecord.isOutgoing()) {
if (!messageRecord.isOutgoing()) {
deliveryStatusView.setNone();
} else if (messageRecord.isPending()) {
deliveryStatusView.setPending();
@@ -264,11 +398,6 @@ public class ConversationItemFooter extends LinearLayout {
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
if (messageRecord.isOutgoing()) {
moveAudioViewsForOutgoing();
} else {
moveAudioViewsForIncoming();
}
showAudioDurationViews();
if (messageRecord.getViewedReceiptCount() > 0) {
@@ -284,41 +413,19 @@ public class ConversationItemFooter extends LinearLayout {
}
}
private void moveAudioViewsForOutgoing() {
removeView(audioSpace);
removeView(audioDuration);
removeView(revealDot);
addView(audioSpace, 0);
addView(revealDot, 0);
addView(audioDuration, 0);
int padStart = ViewUtil.dpToPx(60);
int padLeft = ViewUtil.isLtr(this) ? padStart : 0;
int padRight = ViewUtil.isRtl(this) ? padStart : 0;
audioDuration.setPadding(padLeft, 0, padRight, 0);
}
private void moveAudioViewsForIncoming() {
removeView(audioSpace);
removeView(audioDuration);
removeView(revealDot);
addView(audioSpace);
addView(revealDot);
addView(audioDuration);
audioDuration.setPadding(0, 0, 0, 0);
}
private void showAudioDurationViews() {
audioSpace.setVisibility(View.VISIBLE);
audioDuration.setVisibility(View.GONE);
audioDuration.setVisibility(View.VISIBLE);
revealDot.setVisibility(View.VISIBLE);
playbackSpeedToggleTextView.setVisibility(View.VISIBLE);
}
private void hideAudioDurationViews() {
audioSpace.setVisibility(View.GONE);
audioDuration.setVisibility(View.GONE);
revealDot.setVisibility(View.GONE);
playbackSpeedToggleTextView.setVisibility(View.GONE);
}
public interface OnTouchDelegateChangedListener {
void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView);
}
}

View File

@@ -1,32 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.EditText;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
/**
* Custom styled search view that we can insert into ActionBar menus
*/
public class DarkSearchView extends androidx.appcompat.widget.SearchView {
public DarkSearchView(@NonNull Context context) {
this(context, null);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, R.attr.search_view_style_dark);
}
public DarkSearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
EditText searchText = findViewById(androidx.appcompat.R.id.search_src_text);
searchText.setTextColor(ContextCompat.getColor(context, R.color.signal_text_toolbar_subtitle));
}
}

View File

@@ -1,7 +1,10 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
@@ -9,11 +12,15 @@ import android.text.style.StyleSpan;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
public class FromTextView extends EmojiTextView {
@@ -65,10 +72,17 @@ public class FromTextView extends EmojiTextView {
setText(builder);
if (recipient.isBlocked()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
else if (recipient.isMuted()) setCompoundDrawablesWithIntrinsicBounds(R.drawable.ic_volume_off_grey600_18dp, 0, 0, 0);
if (recipient.isBlocked()) setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_block_grey600_18dp, 0, 0, 0);
else if (recipient.isMuted()) setCompoundDrawablesRelativeWithIntrinsicBounds(getMuted(), null, null, null);
else setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0);
}
private Drawable getMuted() {
Drawable mutedDrawable = Objects.requireNonNull(ContextCompat.getDrawable(getContext(), R.drawable.ic_bell_disabled_16));
mutedDrawable.setBounds(0, 0, ViewUtil.dpToPx(18), ViewUtil.dpToPx(18));
mutedDrawable.setColorFilter(new PorterDuffColorFilter(ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary), PorterDuff.Mode.SRC_IN));
return mutedDrawable;
}
}

View File

@@ -24,6 +24,7 @@ import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@@ -31,11 +32,13 @@ import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftDatabase;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -52,6 +55,7 @@ import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
@@ -59,7 +63,7 @@ import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
implements MicrophoneRecorderView.Listener,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiKeyboardProvider.EmojiEventListener,
EmojiEventListener,
ConversationStickerSuggestionAdapter.EventListener
{
@@ -84,6 +88,7 @@ public class InputPanel extends LinearLayout
private SlideToCancel slideToCancel;
private RecordTime recordTime;
private ValueAnimator quoteAnimator;
private VoiceNoteDraftView voiceNoteDraftView;
private @Nullable Listener listener;
private boolean emojiVisible;
@@ -119,6 +124,7 @@ public class InputPanel extends LinearLayout
this.buttonToggle = findViewById(R.id.button_toggle);
this.recordingContainer = findViewById(R.id.recording_container);
this.recordLockCancel = findViewById(R.id.record_cancel);
this.voiceNoteDraftView = findViewById(R.id.voice_note_draft_view);
this.slideToCancel = new SlideToCancel(findViewById(R.id.slide_to_cancel));
this.microphoneRecorderView = findViewById(R.id.recorder_view);
this.microphoneRecorderView.setListener(this);
@@ -155,6 +161,7 @@ public class InputPanel extends LinearLayout
this.listener = listener;
mediaKeyboard.setOnClickListener(v -> listener.onEmojiToggle());
voiceNoteDraftView.setListener(listener);
}
public void setMediaListener(@NonNull MediaListener listener) {
@@ -230,6 +237,10 @@ public class InputPanel extends LinearLayout
return animator;
}
public boolean hasSaveableContent() {
return getQuote().isPresent() || voiceNoteDraftView.getDraft() != null;
}
public Optional<QuoteModel> getQuote() {
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
@@ -317,7 +328,10 @@ public class InputPanel extends LinearLayout
recordTime.display();
slideToCancel.display();
if (emojiVisible) ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
if (emojiVisible) {
ViewUtil.fadeOut(mediaKeyboard, FADE_TIME, View.INVISIBLE);
}
ViewUtil.fadeOut(composeText, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickCameraToggle, FADE_TIME, View.INVISIBLE);
ViewUtil.fadeOut(quickAudioToggle, FADE_TIME, View.INVISIBLE);
@@ -370,6 +384,10 @@ public class InputPanel extends LinearLayout
this.microphoneRecorderView.cancelAction();
}
public @NonNull Observer<VoiceNotePlaybackState> getPlaybackStateObserver() {
return voiceNoteDraftView.getPlaybackStateObserver();
}
public void setEnabled(boolean enabled) {
composeText.setEnabled(enabled);
mediaKeyboard.setEnabled(enabled);
@@ -386,11 +404,7 @@ public class InputPanel extends LinearLayout
future.addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
if (emojiVisible) ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
ViewUtil.fadeIn(composeText, FADE_TIME);
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
fadeInNormalComposeViews();
}
});
@@ -431,15 +445,57 @@ public class InputPanel extends LinearLayout
microphoneRecorderView.unlockAction();
}
public void showGifMovedTooltip() {
TooltipPopup.forTarget(mediaKeyboard)
.setBackgroundTint(ContextCompat.getColor(getContext(), R.color.signal_accent_primary))
.setTextColor(getResources().getColor(R.color.core_white))
.setText(R.string.ConversationActivity__gifs_are_now_here)
.show(TooltipPopup.POSITION_ABOVE);
public void setVoiceNoteDraft(@Nullable DraftDatabase.Draft voiceNoteDraft) {
if (voiceNoteDraft != null) {
voiceNoteDraftView.setDraft(voiceNoteDraft);
voiceNoteDraftView.setVisibility(VISIBLE);
hideNormalComposeViews();
} else {
voiceNoteDraftView.clearDraft();
ViewUtil.fadeOut(voiceNoteDraftView, FADE_TIME);
fadeInNormalComposeViews();
}
}
public interface Listener {
public @Nullable DraftDatabase.Draft getVoiceNoteDraft() {
return voiceNoteDraftView.getDraft();
}
private void hideNormalComposeViews() {
if (emojiVisible) {
Animation animation = mediaKeyboard.getAnimation();
if (animation != null) {
animation.cancel();
}
mediaKeyboard.setVisibility(View.INVISIBLE);
}
for (Animation animation : Arrays.asList(composeText.getAnimation(), quickCameraToggle.getAnimation(), quickAudioToggle.getAnimation())) {
if (animation != null) {
animation.cancel();
}
}
buttonToggle.animate().cancel();
composeText.setVisibility(View.INVISIBLE);
quickCameraToggle.setVisibility(View.INVISIBLE);
quickAudioToggle.setVisibility(View.INVISIBLE);
}
private void fadeInNormalComposeViews() {
if (emojiVisible) {
ViewUtil.fadeIn(mediaKeyboard, FADE_TIME);
}
ViewUtil.fadeIn(composeText, FADE_TIME);
ViewUtil.fadeIn(quickCameraToggle, FADE_TIME);
ViewUtil.fadeIn(quickAudioToggle, FADE_TIME);
buttonToggle.animate().alpha(1).setDuration(FADE_TIME).start();
}
public interface Listener extends VoiceNoteDraftView.Listener {
void onRecorderStarted();
void onRecorderLocked();
void onRecorderFinished();

View File

@@ -229,7 +229,8 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat {
private int getKeyboardPortraitHeight() {
if (isBubble) {
return getRootView().getHeight() - minCustomKeyboardTopMarginPortrait;
int height = getRootView().getHeight();
return height - (int)(height * 0.45);
}
int keyboardHeight = PreferenceManager.getDefaultSharedPreferences(getContext())

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.components
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.animation.DecelerateInterpolator
import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.R
class PlaybackSpeedToggleTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private val speeds: IntArray = context.resources.getIntArray(R.array.PlaybackSpeedToggleTextView__speeds)
private val labels: Array<String> = context.resources.getStringArray(R.array.PlaybackSpeedToggleTextView__speed_labels)
private var currentSpeedIndex = 0
private var requestedSpeed: Float? = null
var playbackSpeedListener: PlaybackSpeedListener? = null
init {
text = getCurrentLabel()
super.setOnClickListener {
currentSpeedIndex = getNextSpeedIndex()
text = getCurrentLabel()
requestedSpeed = getCurrentSpeed()
playbackSpeedListener?.onPlaybackSpeedChanged(getCurrentSpeed())
}
isClickable = false
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (isClickable) {
when (event?.action) {
MotionEvent.ACTION_DOWN -> zoomIn()
MotionEvent.ACTION_UP -> zoomOut()
MotionEvent.ACTION_CANCEL -> zoomOut()
}
}
return super.onTouchEvent(event)
}
fun clearRequestedSpeed() {
requestedSpeed = null
}
fun setCurrentSpeed(speed: Float) {
if (speed == getCurrentSpeed() || (requestedSpeed != null && requestedSpeed != speed)) {
if (requestedSpeed == speed) {
requestedSpeed = null
}
return
}
requestedSpeed = null
val outOf100 = (speed * 100).toInt()
val index = speeds.indexOf(outOf100)
if (index != -1) {
currentSpeedIndex = index
text = getCurrentLabel()
} else {
throw IllegalArgumentException("Invalid Speed $speed")
}
}
private fun getNextSpeedIndex(): Int = (currentSpeedIndex + 1) % speeds.size
private fun getCurrentSpeed(): Float = speeds[currentSpeedIndex] / 100f
private fun getCurrentLabel(): String = labels[currentSpeedIndex]
private fun zoomIn() {
animate()
.setInterpolator(DecelerateInterpolator())
.setDuration(150L)
.scaleX(1.2f)
.scaleY(1.2f)
}
private fun zoomOut() {
animate()
.setInterpolator(DecelerateInterpolator())
.setDuration(150L)
.scaleX(1f)
.scaleY(1f)
}
override fun setOnClickListener(l: OnClickListener?) {
throw UnsupportedOperationException()
}
interface PlaybackSpeedListener {
fun onPlaybackSpeedChanged(speed: Float)
}
}

View File

@@ -1,13 +1,17 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.text.InputFilter;
import android.util.AttributeSet;
import android.widget.TextView;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiFilter;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
/**
* Custom styled search view that we can insert into ActionBar menus
@@ -23,5 +27,31 @@ public class SearchView extends androidx.appcompat.widget.SearchView {
public SearchView(@NonNull Context context, @Nullable AttributeSet attrs, @AttrRes int defStyleAttr) {
super(context, attrs, defStyleAttr);
initEmojiFilter();
}
private void initEmojiFilter() {
if (!isInEditMode() && !SignalStore.settings().isPreferSystemEmoji()) {
TextView searchText = findViewById(androidx.appcompat.R.id.search_src_text);
if (searchText != null) {
searchText.setFilters(appendEmojiFilter(searchText));
}
}
}
private InputFilter[] appendEmojiFilter(@NonNull TextView view) {
InputFilter[] originalFilters = view.getFilters();
InputFilter[] result;
if (originalFilters != null) {
result = new InputFilter[originalFilters.length + 1];
System.arraycopy(originalFilters, 0, result, 1, originalFilters.length);
} else {
result = new InputFilter[1];
}
result[0] = new EmojiFilter(view);
return result;
}
}

View File

@@ -3,6 +3,12 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Bitmap;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.ShapeDrawable;
import android.graphics.drawable.shapes.RoundRectShape;
import android.graphics.drawable.shapes.Shape;
import android.net.Uri;
import android.util.AttributeSet;
import android.view.View;
@@ -12,6 +18,7 @@ import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.UiThread;
import androidx.core.content.ContextCompat;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
@@ -41,6 +48,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.VideoPlayer;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Arrays;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
@@ -93,6 +101,7 @@ public class ThumbnailView extends FrameLayout {
this.blurhash = findViewById(R.id.thumbnail_blurhash);
this.playOverlay = findViewById(R.id.play_overlay);
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
super.setOnClickListener(new ThumbnailClickDispatcher());
if (attrs != null) {
@@ -103,9 +112,18 @@ public class ThumbnailView extends FrameLayout {
bounds[MAX_HEIGHT] = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_maxHeight, 0);
radius = typedArray.getDimensionPixelSize(R.styleable.ThumbnailView_thumbnail_radius, getResources().getDimensionPixelSize(R.dimen.thumbnail_default_radius));
fit = typedArray.getInt(R.styleable.ThumbnailView_thumbnail_fit, 0) == 1 ? new FitCenter() : new CenterCrop();
int transparentOverlayColor = typedArray.getColor(R.styleable.ThumbnailView_transparent_overlay_color, -1);
if (transparentOverlayColor > 0) {
image.setColorFilter(new PorterDuffColorFilter(transparentOverlayColor, PorterDuff.Mode.SRC_ATOP));
} else {
image.setColorFilter(null);
}
typedArray.recycle();
} else {
radius = getResources().getDimensionPixelSize(R.dimen.message_corner_collapse_radius);
image.setColorFilter(null);
}
}

View File

@@ -6,19 +6,25 @@ import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.Arrays;
import org.thoughtcrime.securesms.util.Util;
import java.util.LinkedList;
import java.util.List;
public class CompositeEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final List<EmojiPageModel> models;
@AttrRes private final int iconAttr;
@NonNull private final List<EmojiPageModel> models;
public CompositeEmojiPageModel(@AttrRes int iconAttr, @NonNull List<EmojiPageModel> models) {
this.iconAttr = iconAttr;
this.models = models;
}
@Override
public String getKey() {
return Util.hasItems(models) ? models.get(0).getKey() : "";
}
public int getIconAttr() {
return iconAttr;
}

View File

@@ -22,4 +22,8 @@ public class Emoji {
public List<String> getVariations() {
return variations;
}
public boolean hasMultipleVariations() {
return variations.size() > 1;
}
}

View File

@@ -0,0 +1,9 @@
package org.thoughtcrime.securesms.components.emoji;
import android.view.KeyEvent;
public interface EmojiEventListener {
void onEmojiSelected(String emoji);
void onKeyEvent(KeyEvent keyEvent);
}

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.components.emoji
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiModel
import org.thoughtcrime.securesms.util.InsetItemDecoration
import org.thoughtcrime.securesms.util.ViewUtil
private val EDGE_LENGTH: Int = ViewUtil.dpToPx(6)
private val HORIZONTAL_INSET: Int = ViewUtil.dpToPx(6)
private val EMOJI_VERTICAL_INSET: Int = ViewUtil.dpToPx(5)
private val HEADER_VERTICAL_INSET: Int = ViewUtil.dpToPx(8)
/**
* Use super class to add insets to the emojis and use the [onDrawOver] to draw the variation
* hint if the emoji has more than one variation.
*/
class EmojiItemDecoration(private val allowVariations: Boolean, private val variationsDrawable: Drawable) : InsetItemDecoration(SetInset()) {
override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
super.onDrawOver(canvas, parent, state)
val adapter: EmojiPageViewGridAdapter? = parent.adapter as? EmojiPageViewGridAdapter
if (allowVariations && adapter != null) {
for (i in 0 until parent.childCount) {
val child: View = parent.getChildAt(i)
val position: Int = parent.getChildAdapterPosition(child)
if (position >= 0 && position <= adapter.itemCount) {
val model = adapter.currentList[position]
if (model is EmojiModel && model.emoji.hasMultipleVariations()) {
variationsDrawable.setBounds(child.right, child.bottom - EDGE_LENGTH, child.right + EDGE_LENGTH, child.bottom)
variationsDrawable.draw(canvas)
}
}
}
}
}
private class SetInset : InsetItemDecoration.SetInset() {
override fun setInset(outRect: Rect, view: View, parent: RecyclerView) {
val isHeader = view.javaClass == AppCompatTextView::class.java
outRect.left = HORIZONTAL_INSET
outRect.right = HORIZONTAL_INSET
outRect.top = if (isHeader) HEADER_VERTICAL_INSET else EMOJI_VERTICAL_INSET
outRect.bottom = if (isHeader) 0 else EMOJI_VERTICAL_INSET
}
}
}

View File

@@ -1,180 +0,0 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.PagerAdapter;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.ResUtil;
import java.util.LinkedList;
import java.util.List;
/**
* A provider to select emoji in the {@link org.thoughtcrime.securesms.components.emoji.MediaKeyboard}.
*
* TODO [alex] -- Are we still using any of this?
*/
public class EmojiKeyboardProvider implements MediaKeyboardProvider,
MediaKeyboardProvider.TabIconProvider,
MediaKeyboardProvider.BackspaceObserver,
VariationSelectorListener
{
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
// TODO [alex] -- We are using this.
public static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
private final Context context;
private final List<EmojiPageModel> models;
private final RecentEmojiPageModel recentModel;
private final EmojiPagerAdapter emojiPagerAdapter;
private final EmojiEventListener emojiEventListener;
private Controller controller;
private int currentPosition;
public EmojiKeyboardProvider(@NonNull Context context, @Nullable EmojiEventListener emojiEventListener) {
this.context = context;
this.emojiEventListener = emojiEventListener;
this.models = new LinkedList<>();
this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
@Override
public void onEmojiSelected(String emoji) {
recentModel.onCodePointSelected(emoji);
SignalStore.emojiValues().setPreferredVariation(emoji);
if (emojiEventListener != null) {
emojiEventListener.onEmojiSelected(emoji);
}
}
@Override
public void onKeyEvent(KeyEvent keyEvent) {
if (emojiEventListener != null) {
emojiEventListener.onKeyEvent(keyEvent);
}
}
}, this);
models.add(recentModel);
models.addAll(EmojiSource.getLatest().getDisplayPages());
currentPosition = recentModel.getEmoji().size() > 0 ? 0 : 1;
}
@Override
public void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider) {
presenter.present(this, emojiPagerAdapter, this, this, null, null, currentPosition);
}
@Override
public void setCurrentPosition(int currentPosition) {
this.currentPosition = currentPosition;
}
@Override
public void setController(@Nullable Controller controller) {
this.controller = controller;
}
@Override
public int getProviderIconView(boolean selected) {
if (selected) {
return R.layout.emoji_keyboard_icon_selected;
} else {
return R.layout.emoji_keyboard_icon;
}
}
@Override
public void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index) {
Drawable drawable = ResUtil.getDrawable(context, models.get(index).getIconAttr());
imageView.setImageDrawable(drawable);
}
@Override
public void onBackspaceClicked() {
if (emojiEventListener != null) {
emojiEventListener.onKeyEvent(DELETE_KEY_EVENT);
}
}
@Override
public void onVariationSelectorStateChanged(boolean open) {
if (controller != null) {
controller.setViewPagerEnabled(!open);
}
}
@Override
public boolean equals(@Nullable Object obj) {
return obj instanceof EmojiKeyboardProvider;
}
private static class EmojiPagerAdapter extends PagerAdapter {
private Context context;
private List<EmojiPageModel> pages;
private EmojiEventListener emojiSelectionListener;
private VariationSelectorListener variationSelectorListener;
public EmojiPagerAdapter(@NonNull Context context,
@NonNull List<EmojiPageModel> pages,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener)
{
super();
this.context = context;
this.pages = pages;
this.emojiSelectionListener = emojiSelectionListener;
this.variationSelectorListener = variationSelectorListener;
}
@Override
public int getCount() {
return pages.size();
}
@Override
public @NonNull Object instantiateItem(@NonNull ViewGroup container, int position) {
EmojiPageView page = new EmojiPageView(context, emojiSelectionListener, variationSelectorListener, true);
page.setModel(pages.get(position));
container.addView(page);
return page;
}
@Override
public void destroyItem(ViewGroup container, int position, Object object) {
container.removeView((View)object);
}
@Override
public void setPrimaryItem(ViewGroup container, int position, Object object) {
EmojiPageView current = (EmojiPageView) object;
current.onSelected();
super.setPrimaryItem(container, position, object);
}
@Override
public boolean isViewFromObject(View view, Object object) {
return view == object;
}
}
public interface EmojiEventListener {
void onEmojiSelected(String emoji);
void onKeyEvent(KeyEvent keyEvent);
}
}

View File

@@ -7,6 +7,7 @@ import androidx.annotation.Nullable;
import java.util.List;
public interface EmojiPageModel {
String getKey();
int getIconAttr();
List<String> getEmoji();
List<Emoji> getDisplayEmoji();

View File

@@ -1,110 +1,136 @@
package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.view.LayoutInflater;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.LinearSmoothScroller;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiHeader;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.EmojiNoResultsModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.util.MappingModelList;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.ViewUtil;
public class EmojiPageView extends FrameLayout implements VariationSelectorListener {
private static final String TAG = Log.tag(EmojiPageView.class);
import java.util.List;
import java.util.Optional;
public class EmojiPageView extends RecyclerView implements VariationSelectorListener {
private EmojiPageModel model;
private AdapterFactory adapterFactory;
private RecyclerView recyclerView;
private RecyclerView.LayoutManager layoutManager;
private LinearLayoutManager layoutManager;
private RecyclerView.OnItemTouchListener scrollDisabler;
private VariationSelectorListener variationSelectorListener;
private EmojiVariationSelectorPopup popup;
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public EmojiPageView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public EmojiPageView(@NonNull Context context,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations)
{
this(context, emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(context, 8), R.layout.emoji_display_item);
super(context);
initialize(emojiSelectionListener, variationSelectorListener, allowVariations);
}
public EmojiPageView(@NonNull Context context,
@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@NonNull RecyclerView.LayoutManager layoutManager,
@LayoutRes int displayItemLayoutResId)
@NonNull LinearLayoutManager layoutManager,
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
super(context);
final View view = LayoutInflater.from(getContext()).inflate(R.layout.emoji_grid_layout, this, true);
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, layoutManager, displayEmojiLayoutResId, displayEmoticonLayoutResId);
}
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations)
{
initialize(emojiSelectionListener, variationSelectorListener, allowVariations, new GridLayoutManager(getContext(), 8), R.layout.emoji_display_item_grid, R.layout.emoji_text_display_item_grid);
}
public void initialize(@NonNull EmojiEventListener emojiSelectionListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@NonNull LinearLayoutManager layoutManager,
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.variationSelectorListener = variationSelectorListener;
this.recyclerView = view.findViewById(R.id.emoji);
this.layoutManager = layoutManager;
this.scrollDisabler = new ScrollDisabler();
this.popup = new EmojiVariationSelectorPopup(context, emojiSelectionListener);
this.popup = new EmojiVariationSelectorPopup(getContext(), emojiSelectionListener);
this.adapterFactory = () -> new EmojiPageViewGridAdapter(popup,
emojiSelectionListener,
this,
allowVariations,
displayItemLayoutResId);
displayEmojiLayoutResId,
displayEmoticonLayoutResId);
recyclerView.setLayoutManager(layoutManager);
recyclerView.setItemAnimator(null);
if (this.layoutManager instanceof GridLayoutManager) {
GridLayoutManager gridLayout = (GridLayoutManager) this.layoutManager;
gridLayout.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() {
@Override
public int getSpanSize(int position) {
if (getAdapter() != null) {
Optional<MappingModel<?>> model = getAdapter().getModel(position);
if (model.isPresent() && (model.get() instanceof EmojiHeader || model.get() instanceof EmojiNoResultsModel)) {
return gridLayout.getSpanCount();
}
}
return 1;
}
});
}
setLayoutManager(layoutManager);
Drawable drawable = DrawableUtil.tint(ContextUtil.requireDrawable(getContext(), R.drawable.triangle_bottom_right_corner), ContextCompat.getColor(getContext(), R.color.signal_button_secondary_text_disabled));
addItemDecoration(new EmojiItemDecoration(allowVariations, drawable));
}
public void presentForEmojiKeyboard() {
recyclerView.setPadding(recyclerView.getPaddingLeft(),
recyclerView.getPaddingTop(),
recyclerView.getPaddingRight(),
recyclerView.getPaddingBottom() + ViewUtil.dpToPx(56));
setPadding(getPaddingLeft(),
getPaddingTop(),
getPaddingRight(),
getPaddingBottom() + ViewUtil.dpToPx(56));
recyclerView.setClipToPadding(false);
setClipToPadding(false);
}
public void onSelected() {
if (model.isDynamic() && recyclerView.getAdapter() != null) {
recyclerView.getAdapter().notifyDataSetChanged();
if (getAdapter() != null) {
getAdapter().notifyDataSetChanged();
}
}
public void setModel(@Nullable EmojiPageModel model) {
this.model = model;
public void setList(@NonNull List<MappingModel<?>> list, @Nullable Runnable commitCallback) {
EmojiPageViewGridAdapter adapter = adapterFactory.create();
recyclerView.setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
public void bindSearchableAdapter(@Nullable EmojiPageModel model) {
this.model = model;
EmojiPageViewGridAdapter adapter = adapterFactory.create();
recyclerView.setAdapter(adapter);
adapter.submitList(getMappingModelList());
}
private @NonNull MappingModelList getMappingModelList() {
MappingModelList mappingModels = new MappingModelList();
if (model != null) {
mappingModels.addAll(Stream.of(model.getDisplayEmoji()).map(EmojiPageViewGridAdapter.EmojiModel::new).toList());
}
return mappingModels;
setAdapter(adapter);
adapter.submitList(list, commitCallback);
}
@Override
@@ -117,8 +143,8 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
if (layoutManager instanceof GridLayoutManager) {
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
int spanCount = Math.max(w / idealWidth, 1);
int idealWidth = getContext().getResources().getDimensionPixelOffset(R.dimen.emoji_drawer_item_width);
int spanCount = Math.max(w / idealWidth, 1);
((GridLayoutManager) layoutManager).setSpanCount(spanCount);
}
@@ -127,9 +153,9 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
@Override
public void onVariationSelectorStateChanged(boolean open) {
if (open) {
recyclerView.addOnItemTouchListener(scrollDisabler);
addOnItemTouchListener(scrollDisabler);
} else {
post(() -> recyclerView.removeOnItemTouchListener(scrollDisabler));
post(() -> removeOnItemTouchListener(scrollDisabler));
}
if (variationSelectorListener != null) {
@@ -138,7 +164,29 @@ public class EmojiPageView extends FrameLayout implements VariationSelectorListe
}
public void setRecyclerNestedScrollingEnabled(boolean enabled) {
recyclerView.setNestedScrollingEnabled(enabled);
setNestedScrollingEnabled(enabled);
}
public void smoothScrollToPositionTop(int position) {
int currentPosition = layoutManager.findFirstCompletelyVisibleItemPosition();
boolean shortTrip = Math.abs(currentPosition - position) < 475;
if (shortTrip) {
RecyclerView.SmoothScroller smoothScroller = new LinearSmoothScroller(getContext()) {
@Override
protected int getVerticalSnapPreference() {
return LinearSmoothScroller.SNAP_TO_START;
}
};
smoothScroller.setTargetPosition(position);
layoutManager.startSmoothScroll(smoothScroller);
} else {
layoutManager.scrollToPositionWithOffset(position, 0);
}
}
public @Nullable EmojiPageViewGridAdapter getAdapter() {
return (EmojiPageViewGridAdapter) super.getAdapter();
}
private static class ScrollDisabler implements RecyclerView.OnItemTouchListener {

View File

@@ -2,38 +2,37 @@ package org.thoughtcrime.securesms.components.emoji;
import android.graphics.drawable.Drawable;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.Space;
import android.widget.TextView;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.jetbrains.annotations.NotNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.MappingViewHolder;
public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWindow.OnDismissListener {
private final VariationSelectorListener variationSelectorListener;
private final VariationSelectorListener variationSelectorListener;
public EmojiPageViewGridAdapter(@NonNull EmojiVariationSelectorPopup popup,
@NonNull EmojiEventListener emojiEventListener,
@NonNull VariationSelectorListener variationSelectorListener,
boolean allowVariations,
@LayoutRes int displayItemLayoutResId)
@LayoutRes int displayEmojiLayoutResId,
@LayoutRes int displayEmoticonLayoutResId)
{
this.variationSelectorListener = variationSelectorListener;
popup.setOnDismissListener(this);
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayItemLayoutResId));
registerFactory(EmojiHeader.class, new LayoutFactory<>(EmojiHeaderViewHolder::new, R.layout.emoji_grid_header));
registerFactory(EmojiModel.class, new LayoutFactory<>(v -> new EmojiViewHolder(v, emojiEventListener, variationSelectorListener, popup, allowVariations), displayEmojiLayoutResId));
registerFactory(EmojiTextModel.class, new LayoutFactory<>(v -> new EmojiTextViewHolder(v, emojiEventListener), displayEmoticonLayoutResId));
registerFactory(EmojiNoResultsModel.class, new LayoutFactory<>(MappingViewHolder.SimpleViewHolder::new, R.layout.emoji_grid_no_results));
}
@Override
@@ -41,21 +40,73 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
variationSelectorListener.onVariationSelectorStateChanged(false);
}
static class EmojiModel implements MappingModel<EmojiModel> {
public static class EmojiHeader implements MappingModel<EmojiHeader>, HasKey {
private final Emoji emoji;
private final String key;
private final int title;
EmojiModel(@NonNull Emoji emoji) {
public EmojiHeader(@NonNull String key, int title) {
this.key = key;
this.title = title;
}
@Override
public @NonNull String getKey() {
return key;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiHeader newItem) {
return title == newItem.title;
}
@Override
public boolean areContentsTheSame(@NonNull EmojiHeader newItem) {
return areItemsTheSame(newItem);
}
}
static class EmojiHeaderViewHolder extends MappingViewHolder<EmojiHeader> {
private final TextView title;
public EmojiHeaderViewHolder(@NonNull View itemView) {
super(itemView);
title = findViewById(R.id.emoji_grid_header_title);
}
@Override
public void bind(@NonNull EmojiHeader model) {
title.setText(model.title);
}
}
public static class EmojiModel implements MappingModel<EmojiModel>, HasKey {
private final String key;
private final Emoji emoji;
public EmojiModel(@NonNull String key, @NonNull Emoji emoji) {
this.key = key;
this.emoji = emoji;
}
@Override
public boolean areItemsTheSame(@NonNull @NotNull EmojiModel newItem) {
public @NonNull String getKey() {
return key;
}
public @NonNull Emoji getEmoji() {
return emoji;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiModel newItem) {
return newItem.emoji.getValue().equals(emoji.getValue());
}
@Override
public boolean areContentsTheSame(@NonNull @NotNull EmojiModel newItem) {
public boolean areContentsTheSame(@NonNull EmojiModel newItem) {
return areItemsTheSame(newItem);
}
}
@@ -67,9 +118,7 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
private final EmojiEventListener emojiEventListener;
private final boolean allowVariations;
private final ImageView imageView;
private final AsciiEmojiView textView;
private final ImageView hintCorner;
private final ImageView imageView;
public EmojiViewHolder(@NonNull View itemView,
@NonNull EmojiEventListener emojiEventListener,
@@ -85,31 +134,22 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
this.allowVariations = allowVariations;
this.imageView = itemView.findViewById(R.id.emoji_image);
this.textView = itemView.findViewById(R.id.emoji_text);
this.hintCorner = itemView.findViewById(R.id.emoji_variation_hint);
}
@Override
public void bind(@NonNull @NotNull EmojiModel model) {
public void bind(@NonNull EmojiModel model) {
final Drawable drawable = EmojiProvider.getEmojiDrawable(imageView.getContext(), model.emoji.getValue());
if (drawable != null) {
textView.setVisibility(View.GONE);
imageView.setVisibility(View.VISIBLE);
imageView.setImageDrawable(drawable);
} else {
textView.setVisibility(View.VISIBLE);
imageView.setVisibility(View.GONE);
textView.setEmoji(model.emoji.getValue());
}
itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(model.emoji.getValue());
});
if (allowVariations && model.emoji.getVariations().size() > 1) {
if (allowVariations && model.emoji.hasMultipleVariations()) {
itemView.setOnLongClickListener(v -> {
popup.dismiss();
popup.setVariations(model.emoji.getVariations());
@@ -117,14 +157,81 @@ public class EmojiPageViewGridAdapter extends MappingAdapter implements PopupWin
variationSelectorListener.onVariationSelectorStateChanged(true);
return true;
});
hintCorner.setVisibility(View.VISIBLE);
} else {
itemView.setOnLongClickListener(null);
hintCorner.setVisibility(View.GONE);
}
}
}
public static class EmojiTextModel implements MappingModel<EmojiTextModel>, HasKey {
private final String key;
private final Emoji emoji;
public EmojiTextModel(@NonNull String key, @NonNull Emoji emoji) {
this.key = key;
this.emoji = emoji;
}
@Override
public @NonNull String getKey() {
return key;
}
public @NonNull Emoji getEmoji() {
return emoji;
}
@Override
public boolean areItemsTheSame(@NonNull EmojiTextModel newItem) {
return newItem.emoji.getValue().equals(emoji.getValue());
}
@Override
public boolean areContentsTheSame(@NonNull EmojiTextModel newItem) {
return areItemsTheSame(newItem);
}
}
static class EmojiTextViewHolder extends MappingViewHolder<EmojiTextModel> {
private final EmojiEventListener emojiEventListener;
private final AsciiEmojiView textView;
public EmojiTextViewHolder(@NonNull View itemView,
@NonNull EmojiEventListener emojiEventListener)
{
super(itemView);
this.emojiEventListener = emojiEventListener;
this.textView = itemView.findViewById(R.id.emoji_text);
}
@Override
public void bind(@NonNull EmojiTextModel model) {
textView.setEmoji(model.emoji.getValue());
itemView.setOnClickListener(v -> {
emojiEventListener.onEmojiSelected(model.emoji.getValue());
});
}
}
public static class EmojiNoResultsModel implements MappingModel<EmojiNoResultsModel> {
@Override
public boolean areItemsTheSame(@NonNull EmojiNoResultsModel newItem) {
return true;
}
@Override
public boolean areContentsTheSame(@NonNull EmojiNoResultsModel newItem) {
return true;
}
}
public interface HasKey {
@NonNull String getKey();
}
public interface VariationSelectorListener {
void onVariationSelectorStateChanged(boolean open);
}

View File

@@ -6,11 +6,9 @@ import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.appcompat.widget.AppCompatImageButton;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider;
import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;

View File

@@ -11,7 +11,6 @@ import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import java.util.List;

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -13,7 +12,6 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import androidx.viewpager.widget.PagerAdapter;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -22,13 +20,7 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
import java.security.Key;
public class MediaKeyboard extends FrameLayout implements InputView,
MediaKeyboardProvider.Presenter,
MediaKeyboardProvider.Controller,
MediaKeyboardBottomTabAdapter.EventListener
{
public class MediaKeyboard extends FrameLayout implements InputView {
private static final String TAG = Log.tag(MediaKeyboard.class);
private static final String EMOJI_SEARCH = "emoji_search_fragment";
@@ -88,60 +80,6 @@ public class MediaKeyboard extends FrameLayout implements InputView,
keyboardPagerFragment.hide();
}
@Override
public void present(@NonNull MediaKeyboardProvider provider,
@NonNull PagerAdapter pagerAdapter,
@NonNull MediaKeyboardProvider.TabIconProvider tabIconProvider,
@Nullable MediaKeyboardProvider.BackspaceObserver backspaceObserver,
@Nullable MediaKeyboardProvider.AddObserver addObserver,
@Nullable MediaKeyboardProvider.SearchObserver searchObserver,
int startingIndex)
{
// if (categoryPager == null) return;
// if (!provider.equals(providers[providerIndex])) return;
// if (keyboardListener != null) keyboardListener.onKeyboardChanged(provider);
//
// boolean isSolo = providers.length == 1;
//
// presentProviderStrip(isSolo);
// presentCategoryPager(pagerAdapter, tabIconProvider, startingIndex);
// presentProviderTabs(providers, providerIndex);
// presentSearchButton(searchObserver);
// presentBackspaceButton(backspaceObserver, isSolo);
// presentAddButton(addObserver);
}
@Override
public int getCurrentPosition() {
// return categoryPager != null ? categoryPager.getCurrentItem() : 0;
return 0;
}
@Override
public void requestDismissal() {
hide(true);
}
@Override
public boolean isVisible() {
return getVisibility() == View.VISIBLE;
}
@Override
public void onTabSelected(int index) {
// if (categoryPager != null) {
// categoryPager.setCurrentItem(index);
// categoryTabs.smoothScrollToPosition(index);
// }
}
@Override
public void setViewPagerEnabled(boolean enabled) {
// if (categoryPager != null) {
// categoryPager.setEnabled(enabled);
// }
}
public void onCloseEmojiSearch() {
onCloseEmojiSearchInternal(true);
}

View File

@@ -1,93 +0,0 @@
package org.thoughtcrime.securesms.components.emoji;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider.TabIconProvider;
import org.thoughtcrime.securesms.mms.GlideRequests;
public class MediaKeyboardBottomTabAdapter extends RecyclerView.Adapter<MediaKeyboardBottomTabAdapter.MediaKeyboardBottomTabViewHolder> {
private final GlideRequests glideRequests;
private final EventListener eventListener;
private TabIconProvider tabIconProvider;
private int activePosition;
private int count;
public MediaKeyboardBottomTabAdapter(@NonNull GlideRequests glideRequests, @NonNull EventListener eventListener) {
this.glideRequests = glideRequests;
this.eventListener = eventListener;
}
@Override
public @NonNull MediaKeyboardBottomTabViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
return new MediaKeyboardBottomTabViewHolder(LayoutInflater.from(viewGroup.getContext()).inflate(R.layout.media_keyboard_bottom_tab_item, viewGroup, false));
}
@Override
public void onBindViewHolder(@NonNull MediaKeyboardBottomTabViewHolder viewHolder, int i) {
viewHolder.bind(glideRequests, eventListener, tabIconProvider, i, i == activePosition);
}
@Override
public void onViewRecycled(@NonNull MediaKeyboardBottomTabViewHolder holder) {
holder.recycle();
}
@Override
public int getItemCount() {
return count;
}
public void setTabIconProvider(@NonNull TabIconProvider iconProvider, int count) {
this.tabIconProvider = iconProvider;
this.count = count;
notifyDataSetChanged();
}
public void setActivePosition(int position) {
this.activePosition = position;
notifyDataSetChanged();
}
static class MediaKeyboardBottomTabViewHolder extends RecyclerView.ViewHolder {
private final ImageView image;
private final View imageSelected;
public MediaKeyboardBottomTabViewHolder(@NonNull View itemView) {
super(itemView);
this.image = itemView.findViewById(R.id.category_icon);
this.imageSelected = itemView.findViewById(R.id.category_icon_selected);
}
void bind(@NonNull GlideRequests glideRequests,
@NonNull EventListener eventListener,
@NonNull TabIconProvider tabIconProvider,
int index,
boolean selected)
{
tabIconProvider.loadCategoryTabIcon(glideRequests, image, index);
image.setAlpha(selected ? 1 : 0.5f);
imageSelected.setSelected(selected);
itemView.setOnClickListener(v -> eventListener.onTabSelected(index));
}
void recycle() {
itemView.setOnClickListener(null);
}
}
public interface EventListener {
void onTabSelected(int index);
}
}

View File

@@ -1,53 +0,0 @@
package org.thoughtcrime.securesms.components.emoji;
import android.widget.ImageView;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewpager.widget.PagerAdapter;
import org.thoughtcrime.securesms.mms.GlideRequests;
public interface MediaKeyboardProvider {
@LayoutRes int getProviderIconView(boolean selected);
/** @return True if the click was handled with provider-specific logic, otherwise false */
void requestPresentation(@NonNull Presenter presenter, boolean isSoloProvider);
void setController(@Nullable Controller controller);
void setCurrentPosition(int currentPosition);
interface BackspaceObserver {
void onBackspaceClicked();
}
interface AddObserver {
void onAddClicked();
}
interface SearchObserver {
void onSearchOpened();
void onSearchClosed();
void onSearchChanged(@NonNull String query);
}
interface Controller {
void setViewPagerEnabled(boolean enabled);
}
interface Presenter {
void present(@NonNull MediaKeyboardProvider provider,
@NonNull PagerAdapter pagerAdapter,
@NonNull TabIconProvider iconProvider,
@Nullable BackspaceObserver backspaceObserver,
@Nullable AddObserver addObserver,
@Nullable SearchObserver searchObserver,
int startingIndex);
int getCurrentPosition();
void requestDismissal();
boolean isVisible();
}
interface TabIconProvider {
void loadCategoryTabIcon(@NonNull GlideRequests glideRequests, @NonNull ImageView imageView, int index);
}
}

View File

@@ -28,6 +28,7 @@ import java.util.List;
public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = Log.tag(RecentEmojiPageModel.class);
private static final int EMOJI_LRU_SIZE = 50;
public static final String KEY = "Recents";
private final SharedPreferences prefs;
private final String preferenceName;
@@ -55,6 +56,11 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
}
@Override
public String getKey() {
return KEY;
}
@Override public int getIconAttr() {
return R.attr.emoji_category_recent;
}
@@ -100,13 +106,4 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
});
}
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {
String[] emojis = new String[emojiSet.size()];
int i = emojiSet.size() - 1;
for (String emoji : emojiSet) {
emojis[i--] = emoji;
}
return emojis;
}
}

View File

@@ -2,39 +2,39 @@ package org.thoughtcrime.securesms.components.emoji;
import android.net.Uri;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import org.thoughtcrime.securesms.emoji.EmojiCategory;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.stream.Collectors;
public class StaticEmojiPageModel implements EmojiPageModel {
@AttrRes private final int iconAttr;
@NonNull private final List<Emoji> emoji;
@Nullable private final Uri sprite;
private final @NonNull EmojiCategory category;
private final @NonNull List<Emoji> emoji;
private final @Nullable Uri sprite;
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull String[] strings, @Nullable Uri sprite) {
List<Emoji> emoji = new ArrayList<>(strings.length);
for (String s : strings) {
emoji.add(new Emoji(Collections.singletonList(s)));
}
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull String[] strings, @Nullable Uri sprite) {
this(category, Arrays.stream(strings).map(s -> new Emoji(Collections.singletonList(s))).collect(Collectors.toList()), sprite);
}
this.iconAttr = iconAttr;
this.emoji = emoji;
public StaticEmojiPageModel(@NonNull EmojiCategory category, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
this.category = category;
this.emoji = Collections.unmodifiableList(emoji);
this.sprite = sprite;
}
public StaticEmojiPageModel(@AttrRes int iconAttr, @NonNull List<Emoji> emoji, @Nullable Uri sprite) {
this.iconAttr = iconAttr;
this.emoji = Collections.unmodifiableList(emoji);
this.sprite = sprite;
@Override
public String getKey() {
return category.getKey();
}
public int getIconAttr() {
return iconAttr;
return category.getIcon();
}
@Override

View File

@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
@@ -43,7 +43,7 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
}

View File

@@ -8,7 +8,7 @@ import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
@@ -44,7 +44,7 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try(SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.recyclerview
import androidx.recyclerview.widget.RecyclerView
/**
* Allows implementor to trigger an animation when the attached recyclerview is
* scrolled.
*/
abstract class OnScrollAnimationHelper : RecyclerView.OnScrollListener() {
private var lastAnimationState = AnimationState.NONE
protected open val duration: Long = 250L
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val newAnimationState = getAnimationState(recyclerView)
if (newAnimationState == lastAnimationState) {
return
}
if (lastAnimationState == AnimationState.NONE) {
setImmediateState(recyclerView)
return
}
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(duration)
AnimationState.SHOW -> show(duration)
}
lastAnimationState = newAnimationState
}
fun setImmediateState(recyclerView: RecyclerView) {
val newAnimationState = getAnimationState(recyclerView)
when (newAnimationState) {
AnimationState.NONE -> throw AssertionError()
AnimationState.HIDE -> hide(0L)
AnimationState.SHOW -> show(0L)
}
lastAnimationState = newAnimationState
}
protected open fun getAnimationState(recyclerView: RecyclerView): AnimationState {
return if (recyclerView.canScrollVertically(-1)) AnimationState.SHOW else AnimationState.HIDE
}
/**
* Fired when the RecyclerView is able to be scrolled up
*/
protected abstract fun show(duration: Long)
/**
* Fired when the RecyclerView is not able to be scrolled up
*/
protected abstract fun hide(duration: Long)
enum class AnimationState {
NONE,
HIDE,
SHOW
}
}

View File

@@ -0,0 +1,22 @@
package org.thoughtcrime.securesms.components.recyclerview
import android.view.View
/**
* Animates in and out a given view. This is intended to be used to show and hide a toolbar shadow,
* but makes no restrictions in this manner.
*/
open class ToolbarShadowAnimationHelper(private val toolbarShadow: View) : OnScrollAnimationHelper() {
override fun show(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(1f)
}
override fun hide(duration: Long) {
toolbarShadow.animate()
.setDuration(duration)
.alpha(0f)
}
}

View File

@@ -22,7 +22,7 @@ public class OutdatedBuildReminder extends Reminder {
}
private static CharSequence getPluralsText(final Context context) {
int days = getDaysUntilExpiry() - 1;
int days = getDaysUntilExpiry();
if (days == 0) {
return context.getString(R.string.OutdatedBuildReminder_your_version_of_signal_will_expire_today);

View File

@@ -8,10 +8,11 @@ import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
open class DSLSettingsActivity : PassphraseRequiredActivity() {
private val dynamicTheme = DynamicNoActionBarTheme()
protected open val dynamicTheme: DynamicTheme = DynamicNoActionBarTheme()
protected lateinit var navController: NavController
private set

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
class DSLSettingsAdapter : MappingAdapter() {
init {
@@ -42,13 +43,9 @@ abstract class PreferenceViewHolder<T : PreferenceModel<T>>(itemView: View) : Ma
it.isEnabled = model.isEnabled
}
if (model.iconId != -1) {
iconView.setImageResource(model.iconId)
iconView.visibility = View.VISIBLE
} else {
iconView.setImageDrawable(null)
iconView.visibility = View.GONE
}
val icon = model.icon?.resolve(context)
iconView.setImageDrawable(icon)
iconView.visible = icon != null
val title = model.title?.resolve(context)
if (title != null) {
@@ -93,13 +90,31 @@ class RadioListPreferenceViewHolder(itemView: View) : PreferenceViewHolder<Radio
summaryView.text = model.listItems[model.selected]
itemView.setOnClickListener {
MaterialAlertDialogBuilder(context)
.setTitle(model.title.resolve(context))
var selection = -1
val builder = MaterialAlertDialogBuilder(context)
.setTitle(model.dialogTitle.resolve(context))
.setSingleChoiceItems(model.listItems, model.selected) { dialog, which ->
model.onSelected(which)
dialog.dismiss()
if (model.confirmAction) {
selection = which
} else {
model.onSelected(which)
dialog.dismiss()
}
}
.show()
if (model.confirmAction) {
builder
.setPositiveButton(android.R.string.ok) { dialog, _ ->
model.onSelected(selection)
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
dialog.dismiss()
}
.show()
} else {
builder.show()
}
}
}
}

View File

@@ -12,21 +12,25 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
abstract class DSLSettingsFragment(
@StringRes private val titleId: Int,
@StringRes private val titleId: Int = -1,
@MenuRes private val menuId: Int = -1,
@LayoutRes layoutId: Int = R.layout.dsl_settings_fragment
) : Fragment(layoutId) {
private lateinit var recyclerView: RecyclerView
private lateinit var toolbarShadowHelper: ToolbarShadowHelper
private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
toolbar.setTitle(titleId)
if (titleId != -1) {
toolbar.setTitle(titleId)
}
toolbar.setNavigationOnClickListener {
requireActivity().onBackPressed()
@@ -39,18 +43,17 @@ abstract class DSLSettingsFragment(
recyclerView = view.findViewById(R.id.recycler)
recyclerView.edgeEffectFactory = EdgeEffectFactory()
toolbarShadowHelper = ToolbarShadowHelper(toolbarShadow)
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
val adapter = DSLSettingsAdapter()
recyclerView.adapter = adapter
recyclerView.addOnScrollListener(toolbarShadowHelper)
recyclerView.addOnScrollListener(scrollAnimationHelper)
bindAdapter(adapter)
}
override fun onResume() {
super.onResume()
toolbarShadowHelper.onScrolled(recyclerView, 0, 0)
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
return ToolbarShadowAnimationHelper(toolbarShadow)
}
abstract fun bindAdapter(adapter: DSLSettingsAdapter)
@@ -65,32 +68,4 @@ abstract class DSLSettingsFragment(
}
}
}
class ToolbarShadowHelper(private val toolbarShadow: View) : RecyclerView.OnScrollListener() {
private var lastAnimationState = ToolbarAnimationState.NONE
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val newAnimationState =
if (recyclerView.canScrollVertically(-1)) ToolbarAnimationState.SHOW else ToolbarAnimationState.HIDE
if (newAnimationState == lastAnimationState) {
return
}
when (newAnimationState) {
ToolbarAnimationState.NONE -> throw AssertionError()
ToolbarAnimationState.HIDE -> toolbarShadow.animate().alpha(0f)
ToolbarAnimationState.SHOW -> toolbarShadow.animate().alpha(1f)
}
lastAnimationState = newAnimationState
}
}
private enum class ToolbarAnimationState {
NONE,
HIDE,
SHOW
}
}

View File

@@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.components.settings
import android.content.Context
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.Drawable
import androidx.annotation.ColorRes
import androidx.annotation.DrawableRes
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
const val NO_TINT = -1
sealed class DSLSettingsIcon {
private data class FromResource(
@DrawableRes private val iconId: Int,
@ColorRes private val iconTintId: Int
) : DSLSettingsIcon() {
override fun resolve(context: Context) = requireNotNull(ContextCompat.getDrawable(context, iconId)).apply {
if (iconTintId != NO_TINT) {
colorFilter = PorterDuffColorFilter(ContextCompat.getColor(context, iconTintId), PorterDuff.Mode.SRC_IN)
}
}
}
private data class FromDrawable(
private val drawable: Drawable
) : DSLSettingsIcon() {
override fun resolve(context: Context): Drawable = drawable
}
abstract fun resolve(context: Context): Drawable
companion object {
@JvmStatic
fun from(@DrawableRes iconId: Int, @ColorRes iconTintId: Int = R.color.signal_icon_tint_primary): DSLSettingsIcon = FromResource(iconId, iconTintId)
@JvmStatic
fun from(drawable: Drawable): DSLSettingsIcon = FromDrawable(drawable)
}
}

View File

@@ -88,7 +88,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
@JvmStatic
fun help(context: Context, startCategoryIndex: Int = 0): Intent {
return getIntentForStartLocation(context, StartLocation.HOME)
return getIntentForStartLocation(context, StartLocation.HELP)
.putExtra(HelpFragment.START_CATEGORY_INDEX, startCategoryIndex)
}

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
@@ -44,7 +45,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__account),
iconId = R.drawable.ic_profile_circle_24,
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_accountSettingsFragment)
}
@@ -52,7 +53,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__linked_devices),
iconId = R.drawable.ic_linked_devices_24,
icon = DSLSettingsIcon.from(R.drawable.ic_linked_devices_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_deviceActivity)
}
@@ -72,7 +73,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__appearance),
iconId = R.drawable.ic_appearance_24,
icon = DSLSettingsIcon.from(R.drawable.ic_appearance_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_appearanceSettingsFragment)
}
@@ -80,7 +81,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__chats),
iconId = R.drawable.ic_message_tinted_bitmap_24,
icon = DSLSettingsIcon.from(R.drawable.ic_message_tinted_bitmap_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_chatsSettingsFragment)
}
@@ -88,7 +89,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
iconId = R.drawable.ic_bell_24,
icon = DSLSettingsIcon.from(R.drawable.ic_bell_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_notificationsSettingsFragment)
}
@@ -96,7 +97,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__privacy),
iconId = R.drawable.ic_lock_24,
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_privacySettingsFragment)
}
@@ -104,7 +105,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__data_and_storage),
iconId = R.drawable.ic_archive_24dp,
icon = DSLSettingsIcon.from(R.drawable.ic_archive_24dp),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_dataAndStorageSettingsFragment)
}
@@ -114,7 +115,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.preferences__help),
iconId = R.drawable.ic_help_24,
icon = DSLSettingsIcon.from(R.drawable.ic_help_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_helpSettingsFragment)
}
@@ -122,7 +123,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
clickPref(
title = DSLSettingsText.from(R.string.AppSettingsFragment__invite_your_friends),
iconId = R.drawable.ic_invite_24,
icon = DSLSettingsIcon.from(R.drawable.ic_invite_24),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_appSettingsFragment_to_inviteActivity)
}
@@ -130,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(R.string.text_secure_normal__men
externalLinkPref(
title = DSLSettingsText.from(R.string.preferences__donate_to_signal),
iconId = R.drawable.ic_heart_24,
icon = DSLSettingsIcon.from(R.drawable.ic_heart_24),
linkId = R.string.donate_url
)

View File

@@ -8,6 +8,7 @@ import android.widget.Toast
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
@@ -242,6 +243,40 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel.setRemoveSenderKeyMinimum(!state.removeSenderKeyMinimium)
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_delay_resends),
summary = DSLSettingsText.from(R.string.preferences__internal_delay_resending_messages_in_response_to_retry_receipts),
isChecked = state.delayResends,
onClick = {
viewModel.setDelayResends(!state.delayResends)
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_calling)
radioPref(
title = DSLSettingsText.from(R.string.preferences__internal_calling_default),
summary = DSLSettingsText.from(BuildConfig.SIGNAL_SFU_URL),
isChecked = state.callingServer == BuildConfig.SIGNAL_SFU_URL,
onClick = {
viewModel.setInternalGroupCallingServer(null)
}
)
BuildConfig.SIGNAL_SFU_INTERNAL_NAMES.zip(BuildConfig.SIGNAL_SFU_INTERNAL_URLS)
.forEach { (name, server) ->
radioPref(
title = DSLSettingsText.from(requireContext().getString(R.string.preferences__internal_calling_s_server, name)),
summary = DSLSettingsText.from(server),
isChecked = state.callingServer == server,
onClick = {
viewModel.setInternalGroupCallingServer(server)
}
)
}
}
}

View File

@@ -11,7 +11,9 @@ data class InternalSettingsState(
val disableAutoMigrationInitiation: Boolean,
val disableAutoMigrationNotification: Boolean,
val forceCensorship: Boolean,
val callingServer: String,
val useBuiltInEmojiSet: Boolean,
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,
val delayResends: Boolean,
)

View File

@@ -70,6 +70,16 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setDelayResends(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.DELAY_RESENDS, enabled)
refresh()
}
fun setInternalGroupCallingServer(server: String?) {
preferenceDataStore.putString(InternalValues.CALLING_SERVER, server)
refresh()
}
private fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
}
@@ -83,9 +93,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
disableAutoMigrationInitiation = SignalStore.internalValues().disableGv1AutoMigrateInitiation(),
disableAutoMigrationNotification = SignalStore.internalValues().disableGv1AutoMigrateNotification(),
forceCensorship = SignalStore.internalValues().forcedCensorship(),
callingServer = SignalStore.internalValues().groupCallingServer(),
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
emojiVersion = null,
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum()
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
delayResends = SignalStore.internalValues().delayResends()
)
class Factory(private val repository: InternalSettingsRepository) : ViewModelProvider.Factory {

View File

@@ -289,7 +289,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
val radioListPreference: RadioListPreference
) : PreferenceModel<LedColorPreference>(
title = radioListPreference.title,
iconId = radioListPreference.iconId,
icon = radioListPreference.icon,
summary = radioListPreference.summary
) {
override fun areContentsTheSame(newItem: LedColorPreference): Boolean {

View File

@@ -430,7 +430,7 @@ class PrivacySettingsFragment : DSLSettingsFragment(R.string.preferences__privac
) : PreferenceModel<ValueClickPreference>(
title = clickPreference.title,
summary = clickPreference.summary,
iconId = clickPreference.iconId,
icon = clickPreference.icon,
isEnabled = clickPreference.isEnabled
) {
override fun areContentsTheSame(newItem: ValueClickPreference): Boolean {

View File

@@ -1,7 +1,8 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.advanced
import android.content.Context
import com.google.firebase.iid.FirebaseInstanceId
import com.google.android.gms.tasks.Tasks
import com.google.firebase.installations.FirebaseInstallations
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.DatabaseFactory
@@ -14,6 +15,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
import java.io.IOException
import java.util.concurrent.ExecutionException
private val TAG = Log.tag(AdvancedPrivacySettingsRepository::class.java)
@@ -29,12 +31,18 @@ class AdvancedPrivacySettingsRepository(private val context: Context) {
Log.w(TAG, e)
}
if (!TextSecurePreferences.isFcmDisabled(context)) {
FirebaseInstanceId.getInstance().deleteInstanceId()
Tasks.await(FirebaseInstallations.getInstance().delete())
}
DisablePushMessagesResult.SUCCESS
} catch (ioe: IOException) {
Log.w(TAG, ioe)
DisablePushMessagesResult.NETWORK_ERROR
} catch (e: InterruptedException) {
Log.w(TAG, "Interrupted while deleting", e)
DisablePushMessagesResult.NETWORK_ERROR
} catch (e: ExecutionException) {
Log.w(TAG, "Error deleting", e.cause)
DisablePushMessagesResult.NETWORK_ERROR
}
consumer(result)

View File

@@ -0,0 +1,90 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.core.app.ActivityCompat
import androidx.core.app.ActivityOptionsCompat
import androidx.core.util.Pair
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DynamicConversationSettingsTheme
import org.thoughtcrime.securesms.util.DynamicTheme
class ConversationSettingsActivity : DSLSettingsActivity(), ConversationSettingsFragment.Callback {
override val dynamicTheme: DynamicTheme = DynamicConversationSettingsTheme()
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
ActivityCompat.postponeEnterTransition(this)
super.onCreate(savedInstanceState, ready)
}
override fun onContentWillRender() {
ActivityCompat.startPostponedEnterTransition(this)
}
override fun finish() {
super.finish()
overridePendingTransition(0, R.anim.slide_fade_to_bottom)
}
companion object {
@JvmStatic
fun createTransitionBundle(context: Context, avatar: View, windowContent: View): Bundle? {
return if (context is Activity) {
ActivityOptionsCompat.makeSceneTransitionAnimation(
context,
Pair.create(avatar, "avatar"),
Pair.create(windowContent, "window_content")
).toBundle()
} else {
null
}
}
@JvmStatic
fun createTransitionBundle(context: Context, avatar: View): Bundle? {
return if (context is Activity) {
ActivityOptionsCompat.makeSceneTransitionAnimation(
context,
avatar,
"avatar",
).toBundle()
} else {
null
}
}
@JvmStatic
fun forGroup(context: Context, groupId: GroupId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(null, ParcelableGroupId.from(groupId))
.build()
.toBundle()
return getIntent(context)
.putExtra(ARG_START_BUNDLE, startBundle)
}
@JvmStatic
fun forRecipient(context: Context, recipientId: RecipientId): Intent {
val startBundle = ConversationSettingsFragmentArgs.Builder(recipientId, null)
.build()
.toBundle()
return getIntent(context)
.putExtra(ARG_START_BUNDLE, startBundle)
}
private fun getIntent(context: Context): Intent {
return Intent(context, ConversationSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.conversation_settings)
}
}
}

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.components.settings.conversation
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
sealed class ConversationSettingsEvent {
class AddToAGroup(
val recipientId: RecipientId,
val groupMembership: List<RecipientId>
) : ConversationSettingsEvent()
class AddMembersToGroup(
val groupId: GroupId,
val selectionWarning: Int,
val selectionLimit: Int,
val groupMembersWithoutSelf: List<RecipientId>
) : ConversationSettingsEvent()
object ShowGroupHardLimitDialog : ConversationSettingsEvent()
class ShowAddMembersToGroupError(
val failureReason: GroupChangeFailureReason
) : ConversationSettingsEvent()
class ShowGroupInvitesSentDialog(
val invitesSentTo: List<Recipient>
) : ConversationSettingsEvent()
class ShowMembersAdded(
val membersAddedCount: Int
) : ConversationSettingsEvent()
class InitiateGroupMigration(
val recipientId: RecipientId
) : ConversationSettingsEvent()
}

View File

@@ -0,0 +1,770 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import app.cash.exhaustive.Exhaustive
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.thoughtcrime.securesms.AvatarPreviewActivity
import org.thoughtcrime.securesms.BlockUnblockDialog
import org.thoughtcrime.securesms.InviteActivity
import org.thoughtcrime.securesms.MediaPreviewActivity
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.PushContactSelectionActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.VerifyIdentityActivity
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.recyclerview.OnScrollAnimationHelper
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.NO_TINT
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.AvatarPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.BioTextPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.GroupDescriptionPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.InternalPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.SharedMediaPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.Utils.formatMutedUntil
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog
import org.thoughtcrime.securesms.groups.ui.addmembers.AddMembersActivity
import org.thoughtcrime.securesms.groups.ui.addtogroup.AddToGroupsActivity
import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment
import org.thoughtcrime.securesms.recipients.ui.sharablegrouplink.ShareableGroupLinkDialogFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.ContextUtil
import org.thoughtcrime.securesms.util.ExpirationUtil
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.wallpaper.ChatWallpaperActivity
private const val REQUEST_CODE_VIEW_CONTACT = 1
private const val REQUEST_CODE_ADD_CONTACT = 2
private const val REQUEST_CODE_ADD_MEMBERS_TO_GROUP = 3
private const val REQUEST_CODE_RETURN_FROM_MEDIA = 4
class ConversationSettingsFragment : DSLSettingsFragment(
layoutId = R.layout.conversation_settings_fragment,
menuId = R.menu.conversation_settings
) {
private val alertTint by lazy { ContextCompat.getColor(requireContext(), R.color.signal_alert_primary) }
private val blockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val unblockIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_block_tinted_24)
}
private val leaveIcon by lazy {
ContextUtil.requireDrawable(requireContext(), R.drawable.ic_leave_tinted_24).apply {
colorFilter = PorterDuffColorFilter(alertTint, PorterDuff.Mode.SRC_IN)
}
}
private val viewModel by viewModels<ConversationSettingsViewModel>(
factoryProducer = {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as? ParcelableGroupId
ConversationSettingsViewModel.Factory(
recipientId = args.recipientId,
groupId = ParcelableGroupId.get(groupId),
repository = ConversationSettingsRepository(requireContext())
)
}
)
private lateinit var callback: Callback
private lateinit var toolbar: Toolbar
private lateinit var toolbarAvatar: AvatarImageView
private lateinit var toolbarTitle: TextView
private lateinit var toolbarBackground: View
private val navController get() = Navigation.findNavController(requireView())
override fun onAttach(context: Context) {
super.onAttach(context)
callback = context as Callback
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toolbar = view.findViewById(R.id.toolbar)
toolbarAvatar = view.findViewById(R.id.toolbar_avatar)
toolbarTitle = view.findViewById(R.id.toolbar_title)
toolbarBackground = view.findViewById(R.id.toolbar_background)
super.onViewCreated(view, savedInstanceState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) {
REQUEST_CODE_ADD_MEMBERS_TO_GROUP -> if (data != null) {
val selected: List<RecipientId> = requireNotNull(data.getParcelableArrayListExtra(PushContactSelectionActivity.KEY_SELECTED_RECIPIENTS))
val progress: SimpleProgressDialog.DismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
viewModel.onAddToGroupComplete(selected) {
progress.dismiss()
}
}
REQUEST_CODE_RETURN_FROM_MEDIA -> viewModel.refreshSharedMedia()
REQUEST_CODE_ADD_CONTACT -> viewModel.refreshRecipient()
REQUEST_CODE_VIEW_CONTACT -> viewModel.refreshRecipient()
}
}
override fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
return ConversationSettingsOnUserScrolledAnimationHelper(toolbarAvatar, toolbarTitle, toolbarBackground, toolbarShadow)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return if (item.itemId == R.id.action_edit) {
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as ParcelableGroupId
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
true
} else {
super.onOptionsItemSelected(item)
}
}
override fun bindAdapter(adapter: DSLSettingsAdapter) {
BioTextPreference.register(adapter)
AvatarPreference.register(adapter)
ButtonStripPreference.register(adapter)
LargeIconClickPreference.register(adapter)
SharedMediaPreference.register(adapter)
RecipientPreference.register(adapter)
InternalPreference.register(adapter)
GroupDescriptionPreference.register(adapter)
LegacyGroupPreference.register(adapter)
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.recipient != Recipient.UNKNOWN) {
toolbarAvatar.buildOptions()
.withQuickContactEnabled(false)
.withUseSelfProfileAvatar(false)
.withFixedSize(ViewUtil.dpToPx(80))
.load(state.recipient)
state.withRecipientSettingsState {
toolbarTitle.text = state.recipient.getDisplayName(requireContext())
}
state.withGroupSettingsState {
toolbarTitle.text = it.groupTitle
toolbar.menu.findItem(R.id.action_edit).isVisible = it.canEditGroupAttributes
}
}
adapter.submitList(getConfiguration(state).toMappingModelList()) {
if (state.isLoaded) {
(view?.parent as? ViewGroup)?.doOnPreDraw {
callback.onContentWillRender()
}
}
}
}
viewModel.events.observe(viewLifecycleOwner) { event ->
@Exhaustive
when (event) {
is ConversationSettingsEvent.AddToAGroup -> handleAddToAGroup(event)
is ConversationSettingsEvent.AddMembersToGroup -> handleAddMembersToGroup(event)
ConversationSettingsEvent.ShowGroupHardLimitDialog -> showGroupHardLimitDialog()
is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event)
is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event)
is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event)
is ConversationSettingsEvent.InitiateGroupMigration -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(parentFragmentManager, event.recipientId)
}
}
}
private fun getConfiguration(state: ConversationSettingsState): DSLConfiguration {
return configure {
if (state.recipient == Recipient.UNKNOWN) {
return@configure
}
customPref(
AvatarPreference.Model(
recipient = state.recipient,
onAvatarClick = { avatar ->
requireActivity().apply {
startActivity(
AvatarPreviewActivity.intentFromRecipientId(this, state.recipient.id),
AvatarPreviewActivity.createTransitionBundle(this, avatar)
)
}
}
)
)
state.withRecipientSettingsState {
customPref(BioTextPreference.RecipientModel(recipient = state.recipient))
}
state.withGroupSettingsState { groupState ->
val groupMembershipDescription = if (groupState.groupId.isV1) {
String.format("%s · %s", groupState.membershipCountDescription, getString(R.string.ManageGroupActivity_legacy_group))
} else if (!groupState.canEditGroupAttributes && groupState.groupDescription.isNullOrEmpty()) {
groupState.membershipCountDescription
} else {
null
}
customPref(
BioTextPreference.GroupModel(
groupTitle = groupState.groupTitle,
groupMembershipDescription = groupMembershipDescription
)
)
if (groupState.groupId.isV2) {
customPref(
GroupDescriptionPreference.Model(
groupId = groupState.groupId,
groupDescription = groupState.groupDescription,
descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify,
canEditGroupAttributes = groupState.canEditGroupAttributes,
onEditGroupDescription = {
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
},
onViewGroupDescription = {
GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify)
}
)
)
} else if (groupState.legacyGroupState != LegacyGroupPreference.State.NONE) {
customPref(
LegacyGroupPreference.Model(
state = groupState.legacyGroupState,
onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) },
onUpgradeClick = { viewModel.initiateGroupUpgrade() },
onMmsWarningClick = { startActivity(Intent(requireContext(), InviteActivity::class.java)) }
)
)
}
}
state.withRecipientSettingsState { recipientState ->
if (recipientState.displayInternalRecipientDetails) {
customPref(
InternalPreference.Model(
recipient = state.recipient,
onDisableProfileSharingClick = {
viewModel.disableProfileSharing()
},
onDeleteSessionClick = {
viewModel.deleteSession()
}
)
)
}
}
customPref(
ButtonStripPreference.Model(
state = state.buttonStripState,
onVideoClick = {
CommunicationActions.startVideoCall(requireActivity(), state.recipient)
},
onAudioClick = {
CommunicationActions.startVoiceCall(requireActivity(), state.recipient)
},
onMuteClick = {
if (!state.buttonStripState.isMuted) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(state.recipient.muteUntil.formatMutedUntil(requireContext()))
.setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ ->
viewModel.unmute()
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
.show()
}
},
onSearchClick = {
val intent = ConversationIntents.createBuilder(requireContext(), state.recipient.id, state.threadId)
.withSearchOpen(true)
.build()
startActivity(intent)
requireActivity().finish()
}
)
)
dividerPref()
val summary = DSLSettingsText.from(formatDisappearingMessagesLifespan(state.disappearingMessagesLifespan))
val icon = if (state.disappearingMessagesLifespan <= 0) {
R.drawable.ic_update_timer_disabled_16
} else {
R.drawable.ic_update_timer_16
}
var enabled = true
state.withGroupSettingsState {
enabled = it.canEditGroupAttributes
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__disappearing_messages),
summary = summary,
icon = DSLSettingsIcon.from(icon),
isEnabled = enabled,
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToAppSettingsExpireTimer()
.setInitialValue(state.disappearingMessagesLifespan)
.setRecipientId(state.recipient.id)
.setForResultMode(false)
navController.navigate(action)
}
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__chat_color_and_wallpaper),
icon = DSLSettingsIcon.from(R.drawable.ic_color_24),
onClick = {
startActivity(ChatWallpaperActivity.createIntent(requireContext(), state.recipient.id))
}
)
if (!state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__sounds_and_notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToSoundsAndNotificationsSettingsFragment(state.recipient.id)
navController.navigate(action)
}
)
}
state.withRecipientSettingsState { recipientState ->
when (recipientState.contactLinkState) {
ContactLinkState.OPEN -> {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__contact_details),
icon = DSLSettingsIcon.from(R.drawable.ic_profile_circle_24),
onClick = {
startActivityForResult(Intent(Intent.ACTION_VIEW, state.recipient.contactUri), REQUEST_CODE_VIEW_CONTACT)
}
)
}
ContactLinkState.ADD -> {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_as_a_contact),
icon = DSLSettingsIcon.from(R.drawable.ic_plus_24),
onClick = {
startActivityForResult(RecipientExporter.export(state.recipient).asAddContactIntent(), REQUEST_CODE_ADD_CONTACT)
}
)
}
ContactLinkState.NONE -> {
}
}
if (recipientState.identityRecord != null) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
onClick = {
startActivity(VerifyIdentityActivity.newIntent(requireActivity(), recipientState.identityRecord))
}
)
}
}
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
dividerPref()
sectionHeaderPref(R.string.recipient_preference_activity__shared_media)
customPref(
SharedMediaPreference.Model(
mediaCursor = state.sharedMedia,
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { mediaRecord, isLtr ->
startActivityForResult(
MediaPreviewActivity.intentFromMediaRecord(requireContext(), mediaRecord, isLtr),
REQUEST_CODE_RETURN_FROM_MEDIA
)
}
)
)
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
onClick = {
startActivity(MediaOverviewActivity.forThread(requireContext(), state.threadId))
}
)
}
state.withRecipientSettingsState { groupState ->
if (groupState.selfHasGroups) {
dividerPref()
val groupsInCommonCount = groupState.allGroupsInCommon.size
sectionHeaderPref(
DSLSettingsText.from(
if (groupsInCommonCount == 0) {
getString(R.string.ManageRecipientActivity_no_groups_in_common)
} else {
resources.getQuantityString(
R.plurals.ManageRecipientActivity_d_groups_in_common,
groupsInCommonCount,
groupsInCommonCount
)
}
)
)
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_to_a_group),
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
onClick = {
viewModel.onAddToGroup()
}
)
)
for (group in groupState.groupsInCommon) {
customPref(
RecipientPreference.Model(
recipient = group,
onClick = {
CommunicationActions.startConversation(requireActivity(), group, null)
requireActivity().finish()
}
)
)
}
if (groupState.canShowMoreGroupsInCommon) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT),
onClick = {
viewModel.revealAllMembers()
}
)
)
}
}
}
state.withGroupSettingsState { groupState ->
val memberCount = groupState.allMembers.size
if (groupState.canAddToGroup || memberCount > 0) {
dividerPref()
sectionHeaderPref(DSLSettingsText.from(resources.getQuantityString(R.plurals.ContactSelectionListFragment_d_members, memberCount, memberCount)))
}
if (groupState.canAddToGroup) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__add_members),
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
onClick = {
viewModel.onAddToGroup()
}
)
)
}
for (member in groupState.members) {
customPref(
RecipientPreference.Model(
recipient = member.member,
isAdmin = member.isAdmin,
onClick = {
RecipientBottomSheetDialogFragment.create(member.member.id, groupState.groupId).show(parentFragmentManager, "BOTTOM")
}
)
)
}
if (groupState.canShowMoreGroupMembers) {
customPref(
LargeIconClickPreference.Model(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__see_all),
icon = DSLSettingsIcon.from(R.drawable.show_more, NO_TINT),
onClick = {
viewModel.revealAllMembers()
}
)
)
}
if (state.recipient.isPushV2Group) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__group_link),
summary = DSLSettingsText.from(if (groupState.groupLinkEnabled) R.string.preferences_on else R.string.preferences_off),
icon = DSLSettingsIcon.from(R.drawable.ic_link_16),
onClick = {
ShareableGroupLinkDialogFragment.create(groupState.groupId.requireV2()).show(parentFragmentManager, "DIALOG")
}
)
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__requests_and_invites),
icon = DSLSettingsIcon.from(R.drawable.ic_update_group_add_16),
onClick = {
startActivity(ManagePendingAndRequestingMembersActivity.newIntent(requireContext(), groupState.groupId.requireV2()))
}
)
if (groupState.isSelfAdmin) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__permissions),
icon = DSLSettingsIcon.from(R.drawable.ic_lock_24),
onClick = {
val action = ConversationSettingsFragmentDirections.actionConversationSettingsFragmentToPermissionsSettingsFragment(ParcelableGroupId.from(groupState.groupId))
navController.navigate(action)
}
)
}
}
if (groupState.canLeave) {
dividerPref()
clickPref(
title = DSLSettingsText.from(R.string.conversation__menu_leave_group, alertTint),
icon = DSLSettingsIcon.from(leaveIcon),
onClick = {
LeaveGroupDialog.handleLeavePushGroup(requireActivity(), groupState.groupId.requirePush(), null)
}
)
}
}
if (state.canModifyBlockedState) {
state.withRecipientSettingsState {
dividerPref()
}
state.withGroupSettingsState {
if (!it.canLeave) {
dividerPref()
}
}
val isBlocked = state.recipient.isBlocked
val isGroup = state.recipient.isPushGroup
val title = when {
isBlocked && isGroup -> R.string.ConversationSettingsFragment__unblock_group
isBlocked -> R.string.ConversationSettingsFragment__unblock
isGroup -> R.string.ConversationSettingsFragment__block_group
else -> R.string.ConversationSettingsFragment__block
}
val titleTint = if (isBlocked) null else alertTint
val blockUnblockIcon = if (isBlocked) unblockIcon else blockIcon
clickPref(
title = DSLSettingsText.from(title, titleTint),
icon = DSLSettingsIcon.from(blockUnblockIcon),
onClick = {
if (state.recipient.isBlocked) {
BlockUnblockDialog.showUnblockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) {
viewModel.unblock()
}
} else {
BlockUnblockDialog.showBlockFor(requireContext(), viewLifecycleOwner.lifecycle, state.recipient) {
viewModel.block()
}
}
}
)
}
}
}
private fun formatDisappearingMessagesLifespan(disappearingMessagesLifespan: Int): String {
return if (disappearingMessagesLifespan <= 0) {
getString(R.string.preferences_off)
} else {
ExpirationUtil.getExpirationDisplayValue(requireContext(), disappearingMessagesLifespan)
}
}
private fun handleAddToAGroup(addToAGroup: ConversationSettingsEvent.AddToAGroup) {
startActivity(AddToGroupsActivity.newIntent(requireContext(), addToAGroup.recipientId, addToAGroup.groupMembership))
}
private fun handleAddMembersToGroup(addMembersToGroup: ConversationSettingsEvent.AddMembersToGroup) {
startActivityForResult(
AddMembersActivity.createIntent(
requireContext(),
addMembersToGroup.groupId,
ContactsCursorLoader.DisplayMode.FLAG_PUSH,
addMembersToGroup.selectionWarning,
addMembersToGroup.selectionLimit,
addMembersToGroup.groupMembersWithoutSelf
),
REQUEST_CODE_ADD_MEMBERS_TO_GROUP
)
}
private fun showGroupHardLimitDialog() {
GroupLimitDialog.showHardLimitMessage(requireContext())
}
private fun showAddMembersToGroupError(showAddMembersToGroupError: ConversationSettingsEvent.ShowAddMembersToGroupError) {
Toast.makeText(requireContext(), GroupErrors.getUserDisplayMessage(showAddMembersToGroupError.failureReason), Toast.LENGTH_LONG).show()
}
private fun showGroupInvitesSentDialog(showGroupInvitesSentDialog: ConversationSettingsEvent.ShowGroupInvitesSentDialog) {
GroupInviteSentDialog.showInvitesSent(requireContext(), showGroupInvitesSentDialog.invitesSentTo)
}
private fun showMembersAdded(showMembersAdded: ConversationSettingsEvent.ShowMembersAdded) {
val string = resources.getQuantityString(
R.plurals.ManageGroupActivity_added,
showMembersAdded.membersAddedCount,
showMembersAdded.membersAddedCount
)
Snackbar.make(requireView(), string, Snackbar.LENGTH_SHORT).setTextColor(Color.WHITE).show()
}
private class ConversationSettingsOnUserScrolledAnimationHelper(
private val toolbarAvatar: View,
private val toolbarTitle: View,
private val toolbarBackground: View,
toolbarShadow: View
) : ToolbarShadowAnimationHelper(toolbarShadow) {
override val duration: Long = 200L
private val actionBarSize = ThemeUtil.getThemedDimen(toolbarShadow.context, R.attr.actionBarSize)
private val rect = Rect()
override fun getAnimationState(recyclerView: RecyclerView): AnimationState {
val layoutManager = recyclerView.layoutManager as LinearLayoutManager
return if (layoutManager.findFirstVisibleItemPosition() == 0) {
val firstChild = requireNotNull(layoutManager.getChildAt(0))
firstChild.getLocalVisibleRect(rect)
if (rect.height() <= actionBarSize) {
AnimationState.SHOW
} else {
AnimationState.HIDE
}
} else {
AnimationState.SHOW
}
}
override fun show(duration: Long) {
super.show(duration)
toolbarAvatar
.animate()
.setDuration(duration)
.translationY(0f)
.alpha(1f)
toolbarTitle
.animate()
.setDuration(duration)
.translationY(0f)
.alpha(1f)
toolbarBackground
.animate()
.setDuration(duration)
.alpha(1f)
}
override fun hide(duration: Long) {
super.hide(duration)
toolbarAvatar
.animate()
.setDuration(duration)
.translationY(ViewUtil.dpToPx(56).toFloat())
.alpha(0f)
toolbarTitle
.animate()
.setDuration(duration)
.translationY(ViewUtil.dpToPx(56).toFloat())
.alpha(0f)
toolbarBackground
.animate()
.setDuration(duration)
.alpha(0f)
}
}
interface Callback {
fun onContentWillRender()
}
}

View File

@@ -0,0 +1,219 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.content.Context
import android.database.Cursor
import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.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.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.libsignal.util.guava.Optional
import org.whispersystems.libsignal.util.guava.Preconditions
import java.io.IOException
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
class ConversationSettingsRepository(
private val context: Context
) {
@WorkerThread
fun getThreadMedia(threadId: Long): Optional<Cursor> {
return if (threadId <= 0) {
Optional.absent()
} else {
Optional.of(DatabaseFactory.getMediaDatabase(context).getGalleryMediaForThread(threadId, MediaDatabase.Sorting.Newest))
}
}
fun getThreadId(recipientId: RecipientId, consumer: (Long) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId))
}
}
fun getThreadId(groupId: GroupId, consumer: (Long) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipientId = Recipient.externalGroupExact(context, groupId).id
consumer(DatabaseFactory.getThreadDatabase(context).getThreadIdIfExistsFor(recipientId))
}
}
fun isInternalRecipientDetailsEnabled(): Boolean = SignalStore.internalValues().recipientDetails()
fun hasGroups(consumer: (Boolean) -> Unit) {
SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) }
}
fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(
DatabaseFactory.getIdentityDatabase(context)
.getIdentity(recipientId)
.orNull()
)
}
}
fun getGroupsInCommon(recipientId: RecipientId, consumer: (List<Recipient>) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(
DatabaseFactory
.getGroupDatabase(context)
.getPushGroupsContainingMember(recipientId)
.asSequence()
.filter { it.members.contains(Recipient.self().id) }
.map(GroupDatabase.GroupRecord::getRecipientId)
.map(Recipient::resolved)
.sortedBy { gr -> gr.getDisplayName(context) }
.toList()
)
}
}
fun getGroupMembership(recipientId: RecipientId, consumer: (List<RecipientId>) -> Unit) {
SignalExecutors.BOUNDED.execute {
val groupDatabase = DatabaseFactory.getGroupDatabase(context)
val groupRecords = groupDatabase.getPushGroupsContainingMember(recipientId)
val groupRecipients = ArrayList<RecipientId>(groupRecords.size)
for (groupRecord in groupRecords) {
groupRecipients.add(groupRecord.recipientId)
}
consumer(groupRecipients)
}
}
fun refreshRecipient(recipientId: RecipientId) {
SignalExecutors.UNBOUNDED.execute {
try {
DirectoryHelper.refreshDirectoryFor(context, Recipient.resolved(recipientId), false)
} catch (e: IOException) {
Log.w(TAG, "Failed to refresh user after adding to contacts.")
}
}
}
fun setMuteUntil(recipientId: RecipientId, until: Long) {
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)
}
}
fun getGroupCapacity(groupId: GroupId, consumer: (GroupCapacityResult) -> Unit) {
SignalExecutors.BOUNDED.execute {
val groupRecord: GroupDatabase.GroupRecord = DatabaseFactory.getGroupDatabase(context).getGroup(groupId).get()
consumer(
if (groupRecord.isV2Group) {
val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup
val pendingMembers: List<RecipientId> = decryptedGroup.pendingMembersList
.map(DecryptedPendingMember::getUuid)
.map(GroupProtoUtil::uuidByteStringToRecipientId)
val members = mutableListOf<RecipientId>()
members.addAll(groupRecord.members)
members.addAll(pendingMembers)
GroupCapacityResult(Recipient.self().id, members, FeatureFlags.groupLimits())
} else {
GroupCapacityResult(Recipient.self().id, groupRecord.members, FeatureFlags.groupLimits())
}
)
}
}
fun addMembers(groupId: GroupId, selected: List<RecipientId>, consumer: (GroupAddMembersResult) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(
try {
val groupActionResult = GroupManager.addMembers(context, groupId.requirePush(), selected)
GroupAddMembersResult.Success(groupActionResult.addedMemberCount, Recipient.resolvedList(groupActionResult.invitedMembers))
} catch (e: Exception) {
GroupAddMembersResult.Failure(GroupChangeFailureReason.fromException(e))
}
)
}
}
fun setMuteUntil(groupId: GroupId, until: Long) {
SignalExecutors.BOUNDED.execute {
val recipientId = Recipient.externalGroupExact(context, groupId).id
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, until)
}
}
fun block(recipientId: RecipientId) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
RecipientUtil.blockNonGroup(context, recipient)
}
}
fun unblock(recipientId: RecipientId) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
RecipientUtil.unblock(context, recipient)
}
}
fun block(groupId: GroupId) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.externalGroupExact(context, groupId)
RecipientUtil.block(context, recipient)
}
}
fun unblock(groupId: GroupId) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.externalGroupExact(context, groupId)
RecipientUtil.unblock(context, recipient)
}
}
fun disableProfileSharingForInternalUser(recipientId: RecipientId) {
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipientId, false)
}
}
fun deleteSessionForInternalUser(recipientId: RecipientId) {
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
}
}
@WorkerThread
fun isMessageRequestAccepted(recipient: Recipient): Boolean {
return RecipientUtil.isMessageRequestAccepted(context, recipient)
}
fun getMembershipCountDescription(liveGroup: LiveGroup): LiveData<String> {
return liveGroup.getMembershipCountDescription(context.resources)
}
fun getExternalPossiblyMigratedGroupRecipientId(groupId: GroupId, consumer: (RecipientId) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(Recipient.externalPossiblyMigratedGroup(context, groupId).id)
}
}
}

View File

@@ -0,0 +1,94 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
data class ConversationSettingsState(
val threadId: Long = -1,
val recipient: Recipient = Recipient.UNKNOWN,
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
val disappearingMessagesLifespan: Int = 0,
val canModifyBlockedState: Boolean = false,
val sharedMedia: Cursor? = null,
val sharedMediaIds: List<Long> = listOf(),
private val sharedMediaLoaded: Boolean = false,
private val specificSettingsState: SpecificSettingsState,
) {
val isLoaded: Boolean = recipient != Recipient.UNKNOWN && sharedMediaLoaded && specificSettingsState.isLoaded
fun withRecipientSettingsState(consumer: (SpecificSettingsState.RecipientSettingsState) -> Unit) {
if (specificSettingsState is SpecificSettingsState.RecipientSettingsState) {
consumer(specificSettingsState)
}
}
fun withGroupSettingsState(consumer: (SpecificSettingsState.GroupSettingsState) -> Unit) {
if (specificSettingsState is SpecificSettingsState.GroupSettingsState) {
consumer(specificSettingsState)
}
}
fun requireRecipientSettingsState(): SpecificSettingsState.RecipientSettingsState = specificSettingsState.requireRecipientSettingsState()
fun requireGroupSettingsState(): SpecificSettingsState.GroupSettingsState = specificSettingsState.requireGroupSettingsState()
}
sealed class SpecificSettingsState {
abstract val isLoaded: Boolean
data class RecipientSettingsState(
val identityRecord: IdentityDatabase.IdentityRecord? = null,
val allGroupsInCommon: List<Recipient> = listOf(),
val groupsInCommon: List<Recipient> = listOf(),
val selfHasGroups: Boolean = false,
val canShowMoreGroupsInCommon: Boolean = false,
val groupsInCommonExpanded: Boolean = false,
val contactLinkState: ContactLinkState = ContactLinkState.NONE,
val displayInternalRecipientDetails: Boolean
) : SpecificSettingsState() {
override val isLoaded: Boolean = true
override fun requireRecipientSettingsState() = this
}
data class GroupSettingsState(
val groupId: GroupId,
val allMembers: List<GroupMemberEntry.FullMember> = listOf(),
val members: List<GroupMemberEntry.FullMember> = listOf(),
val isSelfAdmin: Boolean = false,
val canAddToGroup: Boolean = false,
val canEditGroupAttributes: Boolean = false,
val canLeave: Boolean = false,
val canShowMoreGroupMembers: Boolean = false,
val groupMembersExpanded: Boolean = false,
val groupTitle: String = "",
private val groupTitleLoaded: Boolean = false,
val groupDescription: String? = null,
val groupDescriptionShouldLinkify: Boolean = false,
private val groupDescriptionLoaded: Boolean = false,
val groupLinkEnabled: Boolean = false,
val membershipCountDescription: String = "",
val legacyGroupState: LegacyGroupPreference.State = LegacyGroupPreference.State.NONE
) : SpecificSettingsState() {
override val isLoaded: Boolean = groupTitleLoaded && groupDescriptionLoaded
override fun requireGroupSettingsState(): GroupSettingsState = this
}
open fun requireRecipientSettingsState(): RecipientSettingsState = error("Not a recipient settings state")
open fun requireGroupSettingsState(): GroupSettingsState = error("Not a group settings state")
}
enum class ContactLinkState {
OPEN,
ADD,
NONE
}

View File

@@ -0,0 +1,480 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.AttachmentDatabase
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.CursorUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.libsignal.util.guava.Optional
sealed class ConversationSettingsViewModel(
private val repository: ConversationSettingsRepository,
specificSettingsState: SpecificSettingsState,
) : ViewModel() {
private val openedMediaCursors = HashSet<Cursor>()
@Volatile
private var cleared = false
protected val store = Store(
ConversationSettingsState(
specificSettingsState = specificSettingsState
)
)
protected val internalEvents = SingleLiveEvent<ConversationSettingsEvent>()
private val sharedMediaUpdateTrigger = MutableLiveData(Unit)
val state: LiveData<ConversationSettingsState> = store.stateLiveData
val events: LiveData<ConversationSettingsEvent> = internalEvents
init {
val threadId: LiveData<Long> = Transformations.distinctUntilChanged(Transformations.map(state) { it.threadId })
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
repository.getThreadMedia(tId)
}
store.update(sharedMedia) { cursor, state ->
if (!cleared) {
if (cursor.isPresent) {
openedMediaCursors.add(cursor.get())
}
val ids: List<Long> = cursor.transform<List<Long>> {
val result = mutableListOf<Long>()
while (it.moveToNext()) {
result.add(CursorUtil.requireLong(it, AttachmentDatabase.ROW_ID))
}
result
}.or(listOf())
state.copy(
sharedMedia = cursor.orNull(),
sharedMediaIds = ids,
sharedMediaLoaded = true
)
} else {
cursor.orNull().ensureClosed()
state.copy(sharedMedia = null)
}
}
}
fun refreshSharedMedia() {
sharedMediaUpdateTrigger.postValue(Unit)
}
open fun refreshRecipient(): Unit = error("This ViewModel does not support this interaction")
abstract fun setMuteUntil(muteUntil: Long)
abstract fun unmute()
abstract fun block()
abstract fun unblock()
abstract fun onAddToGroup()
abstract fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit)
abstract fun revealAllMembers()
override fun onCleared() {
cleared = true
store.update { state ->
openedMediaCursors.forEach { it.ensureClosed() }
state.copy(sharedMedia = null)
}
}
private fun Cursor?.ensureClosed() {
if (this != null && !this.isClosed) {
this.close()
}
}
open fun disableProfileSharing(): Unit = error("This ViewModel does not support this interaction")
open fun deleteSession(): Unit = error("This ViewModel does not support this interaction")
open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction")
private class RecipientSettingsViewModel(
private val recipientId: RecipientId,
private val repository: ConversationSettingsRepository
) : ConversationSettingsViewModel(
repository,
SpecificSettingsState.RecipientSettingsState(
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
)
) {
private val liveRecipient = Recipient.live(recipientId)
init {
store.update(liveRecipient.liveData) { recipient, state ->
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isVideoAvailable = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED && !recipient.isSelf,
isAudioAvailable = !recipient.isGroup && !recipient.isSelf,
isAudioSecure = recipient.registered == RecipientDatabase.RegisteredState.REGISTERED,
isMuted = recipient.isMuted,
isMuteAvailable = !recipient.isSelf,
isSearchAvailable = true
),
disappearingMessagesLifespan = recipient.expireMessages,
canModifyBlockedState = !recipient.isSelf,
specificSettingsState = state.requireRecipientSettingsState().copy(
contactLinkState = when {
recipient.isSelf -> ContactLinkState.NONE
recipient.isSystemContact -> ContactLinkState.OPEN
else -> ContactLinkState.ADD
}
)
)
}
repository.getThreadId(recipientId) { threadId ->
store.update { state ->
state.copy(threadId = threadId)
}
}
if (recipientId != Recipient.self().id) {
repository.getGroupsInCommon(recipientId) { groupsInCommon ->
store.update { state ->
val recipientSettings = state.requireRecipientSettingsState()
val canShowMore = !recipientSettings.groupsInCommonExpanded && groupsInCommon.size > 6
state.copy(
specificSettingsState = recipientSettings.copy(
allGroupsInCommon = groupsInCommon,
groupsInCommon = if (!canShowMore) groupsInCommon else groupsInCommon.take(5),
canShowMoreGroupsInCommon = canShowMore
)
)
}
}
repository.hasGroups { hasGroups ->
store.update { state ->
val recipientSettings = state.requireRecipientSettingsState()
state.copy(
specificSettingsState = recipientSettings.copy(
selfHasGroups = hasGroups
)
)
}
}
repository.getIdentity(recipientId) { identityRecord ->
store.update { state ->
state.copy(specificSettingsState = state.requireRecipientSettingsState().copy(identityRecord = identityRecord))
}
}
}
}
override fun onAddToGroup() {
repository.getGroupMembership(recipientId) {
internalEvents.postValue(ConversationSettingsEvent.AddToAGroup(recipientId, it))
}
}
override fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit) {
}
override fun revealAllMembers() {
store.update { state ->
state.copy(
specificSettingsState = state.requireRecipientSettingsState().copy(
groupsInCommon = state.requireRecipientSettingsState().allGroupsInCommon,
groupsInCommonExpanded = true,
canShowMoreGroupsInCommon = false
)
)
}
}
override fun refreshRecipient() {
repository.refreshRecipient(recipientId)
}
override fun setMuteUntil(muteUntil: Long) {
repository.setMuteUntil(recipientId, muteUntil)
}
override fun unmute() {
repository.setMuteUntil(recipientId, 0)
}
override fun block() {
repository.block(recipientId)
}
override fun unblock() {
repository.unblock(recipientId)
}
override fun disableProfileSharing() {
repository.disableProfileSharingForInternalUser(recipientId)
}
override fun deleteSession() {
repository.deleteSessionForInternalUser(recipientId)
}
}
private class GroupSettingsViewModel(
private val groupId: GroupId,
private val repository: ConversationSettingsRepository
) : ConversationSettingsViewModel(repository, SpecificSettingsState.GroupSettingsState(groupId)) {
private val liveGroup = LiveGroup(groupId)
init {
store.update(liveGroup.groupRecipient) { recipient, state ->
state.copy(
recipient = recipient,
buttonStripState = ButtonStripPreference.State(
isVideoAvailable = recipient.isPushV2Group,
isAudioAvailable = false,
isAudioSecure = recipient.isPushV2Group,
isMuted = recipient.isMuted,
isMuteAvailable = true,
isSearchAvailable = true
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
specificSettingsState = state.requireGroupSettingsState().copy(
legacyGroupState = getLegacyGroupState(recipient)
)
)
}
repository.getThreadId(groupId) { threadId ->
store.update { state ->
state.copy(threadId = threadId)
}
}
store.update(liveGroup.selfCanEditGroupAttributes()) { selfCanEditGroupAttributes, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
canEditGroupAttributes = selfCanEditGroupAttributes
)
)
}
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
isSelfAdmin = isSelfAdmin
)
)
}
store.update(liveGroup.expireMessages) { expireMessages, state ->
state.copy(
disappearingMessagesLifespan = expireMessages
)
}
store.update(liveGroup.selfCanAddMembers()) { canAddMembers, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
canAddToGroup = canAddMembers
)
)
}
store.update(liveGroup.fullMembers) { fullMembers, state ->
val groupState = state.requireGroupSettingsState()
val canShowMore = !groupState.groupMembersExpanded && fullMembers.size > 6
state.copy(
specificSettingsState = groupState.copy(
allMembers = fullMembers,
members = if (!canShowMore) fullMembers else fullMembers.take(5),
canShowMoreGroupMembers = canShowMore
)
)
}
val isMessageRequestAccepted: LiveData<Boolean> = LiveDataUtil.mapAsync(liveGroup.groupRecipient) { r -> repository.isMessageRequestAccepted(r) }
val descriptionState: LiveData<DescriptionState> = LiveDataUtil.combineLatest(liveGroup.description, isMessageRequestAccepted, ::DescriptionState)
store.update(descriptionState) { d, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
groupDescription = d.description,
groupDescriptionShouldLinkify = d.canLinkify,
groupDescriptionLoaded = true
)
)
}
store.update(liveGroup.isActive) { isActive, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
canLeave = isActive && groupId.isPush
)
)
}
store.update(liveGroup.title) { title, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
groupTitle = title,
groupTitleLoaded = true
)
)
}
store.update(liveGroup.groupLink) { groupLink, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
groupLinkEnabled = groupLink.isEnabled
)
)
}
store.update(repository.getMembershipCountDescription(liveGroup)) { description, state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
membershipCountDescription = description
)
)
}
}
private fun getLegacyGroupState(recipient: Recipient): LegacyGroupPreference.State {
val showLegacyInfo = recipient.requireGroupId().isV1
return if (showLegacyInfo && recipient.participants.size > FeatureFlags.groupLimits().hardLimit) {
LegacyGroupPreference.State.TOO_LARGE
} else if (showLegacyInfo) {
LegacyGroupPreference.State.UPGRADE
} else if (groupId.isMms) {
LegacyGroupPreference.State.MMS_WARNING
} else {
LegacyGroupPreference.State.NONE
}
}
override fun onAddToGroup() {
repository.getGroupCapacity(groupId) { capacityResult ->
if (capacityResult.getRemainingCapacity() > 0) {
internalEvents.postValue(
ConversationSettingsEvent.AddMembersToGroup(
groupId,
capacityResult.getSelectionWarning(),
capacityResult.getSelectionLimit(),
capacityResult.getMembersWithoutSelf()
)
)
} else {
internalEvents.postValue(ConversationSettingsEvent.ShowGroupHardLimitDialog)
}
}
}
override fun onAddToGroupComplete(selected: List<RecipientId>, onComplete: () -> Unit) {
repository.addMembers(groupId, selected) {
ThreadUtil.runOnMain { onComplete() }
when (it) {
is GroupAddMembersResult.Success -> {
if (it.newMembersInvited.isNotEmpty()) {
internalEvents.postValue(ConversationSettingsEvent.ShowGroupInvitesSentDialog(it.newMembersInvited))
}
if (it.numberOfMembersAdded > 0) {
internalEvents.postValue(ConversationSettingsEvent.ShowMembersAdded(it.numberOfMembersAdded))
}
}
is GroupAddMembersResult.Failure -> internalEvents.postValue(ConversationSettingsEvent.ShowAddMembersToGroupError(it.reason))
}
}
}
override fun revealAllMembers() {
store.update { state ->
state.copy(
specificSettingsState = state.requireGroupSettingsState().copy(
members = state.requireGroupSettingsState().allMembers,
groupMembersExpanded = true,
canShowMoreGroupMembers = false
)
)
}
}
override fun setMuteUntil(muteUntil: Long) {
repository.setMuteUntil(groupId, muteUntil)
}
override fun unmute() {
repository.setMuteUntil(groupId, 0)
}
override fun block() {
repository.block(groupId)
}
override fun unblock() {
repository.unblock(groupId)
}
override fun initiateGroupUpgrade() {
repository.getExternalPossiblyMigratedGroupRecipientId(groupId) {
internalEvents.postValue(ConversationSettingsEvent.InitiateGroupMigration(it))
}
}
}
class Factory(
private val recipientId: RecipientId? = null,
private val groupId: GroupId? = null,
private val repository: ConversationSettingsRepository,
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(
modelClass.cast(
when {
recipientId != null -> RecipientSettingsViewModel(recipientId, repository)
groupId != null -> GroupSettingsViewModel(groupId, repository)
else -> error("One of RecipientId or GroupId required.")
}
)
)
}
}
private class DescriptionState(
val description: String?,
val canLinkify: Boolean
)
}

View File

@@ -0,0 +1,15 @@
package org.thoughtcrime.securesms.components.settings.conversation
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import org.thoughtcrime.securesms.recipients.Recipient
sealed class GroupAddMembersResult {
class Success(
val numberOfMembersAdded: Int,
val newMembersInvited: List<Recipient>
) : GroupAddMembersResult()
class Failure(
val reason: GroupChangeFailureReason
) : GroupAddMembersResult()
}

View File

@@ -0,0 +1,46 @@
package org.thoughtcrime.securesms.components.settings.conversation
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.groups.SelectionLimits
import org.thoughtcrime.securesms.recipients.RecipientId
class GroupCapacityResult(
private val selfId: RecipientId,
private val members: List<RecipientId>,
private val selectionLimits: SelectionLimits
) {
fun getMembers(): List<RecipientId?> {
return members
}
fun getSelectionLimit(): Int {
if (!selectionLimits.hasHardLimit()) {
return ContactSelectionListFragment.NO_LIMIT
}
val containsSelf = members.indexOf(selfId) != -1
return selectionLimits.hardLimit - if (containsSelf) 1 else 0
}
fun getSelectionWarning(): Int {
if (!selectionLimits.hasRecommendedLimit()) {
return ContactSelectionListFragment.NO_LIMIT
}
val containsSelf = members.indexOf(selfId) != -1
return selectionLimits.recommendedLimit - if (containsSelf) 1 else 0
}
fun getRemainingCapacity(): Int {
return selectionLimits.hardLimit - members.size
}
fun getMembersWithoutSelf(): List<RecipientId> {
val recipientIds = ArrayList<RecipientId>(members.size)
for (recipientId in members) {
if (recipientId != selfId) {
recipientIds.add(recipientId)
}
}
return recipientIds
}
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.components.settings.conversation.permissions
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
sealed class PermissionsSettingsEvents {
class GroupChangeError(val reason: GroupChangeFailureReason) : PermissionsSettingsEvents()
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.components.settings.conversation.permissions
import android.widget.Toast
import androidx.annotation.StringRes
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.groups.ParcelableGroupId
import org.thoughtcrime.securesms.groups.ui.GroupErrors
class PermissionsSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__permissions
) {
private val permissionsOptions: Array<String> by lazy {
resources.getStringArray(R.array.PermissionsSettingsFragment__editor_labels)
}
private val viewModel: PermissionsSettingsViewModel by viewModels(
factoryProducer = {
val args = PermissionsSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = requireNotNull(ParcelableGroupId.get(args.groupId as ParcelableGroupId))
val repository = PermissionsSettingsRepository(requireContext())
PermissionsSettingsViewModel.Factory(groupId, repository)
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
viewModel.events.observe(viewLifecycleOwner) { event ->
when (event) {
is PermissionsSettingsEvents.GroupChangeError -> handleGroupChangeError(event)
}
}
}
private fun handleGroupChangeError(groupChangeError: PermissionsSettingsEvents.GroupChangeError) {
Toast.makeText(context, GroupErrors.getUserDisplayMessage(groupChangeError.reason), Toast.LENGTH_LONG).show()
}
private fun getConfiguration(state: PermissionsSettingsState): DSLConfiguration {
return configure {
radioListPref(
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__add_members),
isEnabled = state.selfCanEditSettings,
listItems = permissionsOptions,
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_add_new_members),
selected = getSelected(state.nonAdminCanAddMembers),
confirmAction = true,
onSelected = {
viewModel.setNonAdminCanAddMembers(it == 1)
}
)
radioListPref(
title = DSLSettingsText.from(R.string.PermissionsSettingsFragment__edit_group_info),
isEnabled = state.selfCanEditSettings,
listItems = permissionsOptions,
dialogTitle = DSLSettingsText.from(R.string.PermissionsSettingsFragment__who_can_edit_this_groups_info),
selected = getSelected(state.nonAdminCanEditGroupInfo),
confirmAction = true,
onSelected = {
viewModel.setNonAdminCanEditGroupInfo(it == 1)
}
)
}
}
@StringRes
private fun getSelected(isNonAdminAllowed: Boolean): Int {
return if (isNonAdminAllowed) {
1
} else {
0
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components.settings.conversation.permissions
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupChangeException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.groups.ui.GroupChangeErrorCallback
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason
import java.io.IOException
private val TAG = Log.tag(PermissionsSettingsRepository::class.java)
class PermissionsSettingsRepository(private val context: Context) {
fun applyMembershipRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
try {
GroupManager.applyMembershipAdditionRightsChange(context, groupId.requireV2(), newRights)
} catch (e: GroupChangeException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
} catch (e: IOException) {
Log.w(TAG, e)
error.onError(GroupChangeFailureReason.fromException(e))
}
}
}
fun applyAttributesRightsChange(groupId: GroupId, newRights: GroupAccessControl, error: GroupChangeErrorCallback) {
SignalExecutors.UNBOUNDED.execute {
try {
GroupManager.applyAttributesRightsChange(context, groupId.requireV2(), newRights)
} 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

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

View File

@@ -0,0 +1,66 @@
package org.thoughtcrime.securesms.components.settings.conversation.permissions
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
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.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
class PermissionsSettingsViewModel(
private val groupId: GroupId,
private val repository: PermissionsSettingsRepository
) : ViewModel() {
private val store = Store(PermissionsSettingsState())
private val liveGroup = LiveGroup(groupId)
private val internalEvents = SingleLiveEvent<PermissionsSettingsEvents>()
val state: LiveData<PermissionsSettingsState> = store.stateLiveData
val events: LiveData<PermissionsSettingsEvents> = internalEvents
init {
store.update(liveGroup.isSelfAdmin) { isSelfAdmin, state ->
state.copy(selfCanEditSettings = isSelfAdmin)
}
store.update(liveGroup.membershipAdditionAccessControl) { membershipAdditionAccessControl, state ->
state.copy(nonAdminCanAddMembers = membershipAdditionAccessControl == GroupAccessControl.ALL_MEMBERS)
}
store.update(liveGroup.attributesAccessControl) { attributesAccessControl, state ->
state.copy(nonAdminCanEditGroupInfo = attributesAccessControl == GroupAccessControl.ALL_MEMBERS)
}
}
fun setNonAdminCanAddMembers(nonAdminCanAddMembers: Boolean) {
repository.applyMembershipRightsChange(groupId, nonAdminCanAddMembers.asGroupAccessControl()) { reason ->
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
}
}
fun setNonAdminCanEditGroupInfo(nonAdminCanEditGroupInfo: Boolean) {
repository.applyAttributesRightsChange(groupId, nonAdminCanEditGroupInfo.asGroupAccessControl()) { reason ->
internalEvents.postValue(PermissionsSettingsEvents.GroupChangeError(reason))
}
}
private fun Boolean.asGroupAccessControl(): GroupAccessControl {
return if (this) {
GroupAccessControl.ALL_MEMBERS
} else {
GroupAccessControl.ONLY_ADMINS
}
}
class Factory(
private val groupId: GroupId,
private val repository: PermissionsSettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(PermissionsSettingsViewModel(groupId, repository)))
}
}
}

View File

@@ -0,0 +1,45 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
/**
* Renders a large avatar (80dp) for a given Recipient.
*/
object AvatarPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_avatar_preference_item))
}
class Model(
val recipient: Recipient,
val onAvatarClick: (View) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient == newItem.recipient
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && recipient.hasSameContent(newItem.recipient)
}
}
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")
}
override fun bind(model: Model) {
avatar.setAvatar(model.recipient)
avatar.disableQuickContact()
avatar.setOnClickListener { model.onAvatarClick(avatar) }
}
}
}

View File

@@ -0,0 +1,112 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.content.ClipData
import android.content.Context
import android.view.View
import android.widget.TextView
import android.widget.Toast
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ServiceUtil
/**
* Renders name, description, about, etc. for a given group or recipient.
*/
object BioTextPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(RecipientModel::class.java, MappingAdapter.LayoutFactory(::RecipientViewHolder, R.layout.conversation_settings_bio_preference_item))
adapter.registerFactory(GroupModel::class.java, MappingAdapter.LayoutFactory(::GroupViewHolder, R.layout.conversation_settings_bio_preference_item))
}
abstract class BioTextPreferenceModel<T : BioTextPreferenceModel<T>> : PreferenceModel<T>() {
abstract fun getHeadlineText(context: Context): String
abstract fun getSubhead1Text(): String?
abstract fun getSubhead2Text(): String?
}
class RecipientModel(
private val recipient: Recipient,
) : BioTextPreferenceModel<RecipientModel>() {
override fun getHeadlineText(context: Context): String = recipient.getDisplayNameOrUsername(context)
override fun getSubhead1Text(): String? = recipient.combinedAboutAndEmoji
override fun getSubhead2Text(): String? = recipient.e164.transform(PhoneNumberFormatter::prettyPrint).orNull()
override fun areContentsTheSame(newItem: RecipientModel): Boolean {
return super.areContentsTheSame(newItem) && newItem.recipient.hasSameContent(recipient)
}
override fun areItemsTheSame(newItem: RecipientModel): Boolean {
return newItem.recipient.id == recipient.id
}
}
class GroupModel(
val groupTitle: String,
val groupMembershipDescription: String?
) : BioTextPreferenceModel<GroupModel>() {
override fun getHeadlineText(context: Context): String = groupTitle
override fun getSubhead1Text(): String? = groupMembershipDescription
override fun getSubhead2Text(): String? = null
override fun areContentsTheSame(newItem: GroupModel): Boolean {
return super.areContentsTheSame(newItem) &&
groupTitle == newItem.groupTitle &&
groupMembershipDescription == newItem.groupMembershipDescription
}
override fun areItemsTheSame(newItem: GroupModel): Boolean {
return true
}
}
private abstract class BioTextViewHolder<T : BioTextPreferenceModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
private val headline: TextView = itemView.findViewById(R.id.bio_preference_headline)
private val subhead1: TextView = itemView.findViewById(R.id.bio_preference_subhead_1)
protected val subhead2: TextView = itemView.findViewById(R.id.bio_preference_subhead_2)
override fun bind(model: T) {
headline.text = model.getHeadlineText(context)
model.getSubhead1Text().let {
subhead1.text = it
subhead1.visibility = if (it == null) View.GONE else View.VISIBLE
}
model.getSubhead2Text().let {
subhead2.text = it
subhead2.visibility = if (it == null) View.GONE else View.VISIBLE
}
}
}
private class RecipientViewHolder(itemView: View) : BioTextViewHolder<RecipientModel>(itemView) {
override fun bind(model: RecipientModel) {
super.bind(model)
val phoneNumber = model.getSubhead2Text()
if (!phoneNumber.isNullOrEmpty()) {
subhead2.setOnLongClickListener {
val clipboardManager = ServiceUtil.getClipboardManager(context)
clipboardManager.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.ConversationSettingsFragment__phone_number), subhead2.text.toString()))
Toast.makeText(context, R.string.ConversationSettingsFragment__copied_phone_number_to_clipboard, Toast.LENGTH_SHORT).show()
true
}
} else {
subhead2.setOnLongClickListener(null)
}
}
}
private class GroupViewHolder(itemView: View) : BioTextViewHolder<GroupModel>(itemView)
}

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.content.res.AppCompatResources
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* Renders a configurable strip of buttons
*/
object ButtonStripPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_button_strip))
}
class Model(
val state: State,
val background: DSLSettingsIcon? = null,
val onMessageClick: () -> Unit = {},
val onVideoClick: () -> Unit = {},
val onAudioClick: () -> Unit = {},
val onMuteClick: () -> Unit = {},
val onSearchClick: () -> Unit = {}
) : PreferenceModel<Model>() {
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) && state == newItem.state
}
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val message: View = itemView.findViewById(R.id.message)
private val messageLabel: View = itemView.findViewById(R.id.message_label)
private val videoCall: View = itemView.findViewById(R.id.start_video)
private val videoLabel: View = itemView.findViewById(R.id.start_video_label)
private val audioCall: ImageView = itemView.findViewById(R.id.start_audio)
private val audioLabel: TextView = itemView.findViewById(R.id.start_audio_label)
private val mute: ImageView = itemView.findViewById(R.id.mute)
private val muteLabel: TextView = itemView.findViewById(R.id.mute_label)
private val search: View = itemView.findViewById(R.id.search)
private val searchLabel: View = itemView.findViewById(R.id.search_label)
override fun bind(model: Model) {
message.visible = model.state.isMessageAvailable
messageLabel.visible = model.state.isMessageAvailable
videoCall.visible = model.state.isVideoAvailable
videoLabel.visible = model.state.isVideoAvailable
audioCall.visible = model.state.isAudioAvailable
audioLabel.visible = model.state.isAudioAvailable
mute.visible = model.state.isMuteAvailable
muteLabel.visible = model.state.isMuteAvailable
search.visible = model.state.isSearchAvailable
searchLabel.visible = model.state.isSearchAvailable
if (model.state.isAudioSecure) {
audioLabel.setText(R.string.ConversationSettingsFragment__audio)
audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_24))
} else {
audioLabel.setText(R.string.ConversationSettingsFragment__call)
audioCall.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_phone_right_unlock_primary_accent_24))
}
if (model.state.isMuted) {
mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_disabled_24))
muteLabel.setText(R.string.ConversationSettingsFragment__muted)
} else {
mute.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_bell_24))
muteLabel.setText(R.string.ConversationSettingsFragment__mute)
}
if (model.background != null) {
listOf(message, videoCall, audioCall, mute, search).forEach {
it.background = model.background.resolve(context)
}
}
message.setOnClickListener { model.onMessageClick() }
videoCall.setOnClickListener { model.onVideoClick() }
audioCall.setOnClickListener { model.onAudioClick() }
mute.setOnClickListener { model.onMuteClick() }
search.setOnClickListener { model.onSearchClick() }
}
}
data class State(
val isMessageAvailable: Boolean = false,
val isVideoAvailable: Boolean = false,
val isAudioAvailable: Boolean = false,
val isMuteAvailable: Boolean = false,
val isSearchAvailable: Boolean = false,
val isAudioSecure: Boolean = false,
val isMuted: Boolean = false,
)
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.util.LongClickMovementMethod
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
object GroupDescriptionPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_group_description_preference))
}
class Model(
private val groupId: GroupId,
val groupDescription: String?,
val descriptionShouldLinkify: Boolean,
val canEditGroupAttributes: Boolean,
val onEditGroupDescription: () -> Unit,
val onViewGroupDescription: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return groupId == newItem.groupId
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
groupDescription == newItem.groupDescription &&
descriptionShouldLinkify == newItem.descriptionShouldLinkify &&
canEditGroupAttributes == newItem.canEditGroupAttributes
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val groupDescriptionTextView: EmojiTextView = findViewById(R.id.manage_group_description)
override fun bind(model: Model) {
groupDescriptionTextView.movementMethod = LongClickMovementMethod.getInstance(context)
if (model.groupDescription.isNullOrEmpty()) {
if (model.canEditGroupAttributes) {
groupDescriptionTextView.setOverflowText(null)
groupDescriptionTextView.setText(R.string.ManageGroupActivity_add_group_description)
groupDescriptionTextView.setOnClickListener { model.onEditGroupDescription() }
}
} else {
groupDescriptionTextView.setOnClickListener(null)
GroupDescriptionUtil.setText(
context,
groupDescriptionTextView,
model.groupDescription,
model.descriptionShouldLinkify
) {
model.onViewGroupDescription()
}
}
}
}
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.Hex
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import java.util.UUID
object InternalPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_internal_preference))
}
class Model(
private val recipient: Recipient,
val onDisableProfileSharingClick: () -> Unit,
val onDeleteSessionClick: () -> Unit
) : PreferenceModel<Model>() {
val body: String get() {
return String.format(
"""
-- Profile Name --
[${recipient.profileName.givenName}] [${recipient.profileName.familyName}]
-- Profile Sharing --
${recipient.isProfileSharing}
-- Profile Key (Base64) --
${recipient.profileKey?.let(Base64::encodeBytes) ?: "None"}
-- Profile Key (Hex) --
${recipient.profileKey?.let(Hex::toStringCondensed) ?: "None"}
-- Sealed Sender Mode --
${recipient.unidentifiedAccessMode}
-- UUID --
${recipient.uuid.transform { obj: UUID -> obj.toString() }.or("None")}
-- RecipientId --
${recipient.id.serialize()}
""".trimIndent(),
)
}
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient == newItem.recipient
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val body: TextView = itemView.findViewById(R.id.internal_preference_body)
private val disableProfileSharing: View = itemView.findViewById(R.id.internal_disable_profile_sharing)
private val deleteSession: View = itemView.findViewById(R.id.internal_delete_session)
override fun bind(model: Model) {
body.text = model.body
disableProfileSharing.setOnClickListener { model.onDisableProfileSharingClick() }
deleteSession.setOnClickListener { model.onDeleteSessionClick() }
}
}
}

View File

@@ -0,0 +1,32 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.util.MappingAdapter
/**
* Renders a preference line item with a larger (40dp) icon
*/
object LargeIconClickPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.large_icon_preference_item))
}
class Model(
override val title: DSLSettingsText?,
override val icon: DSLSettingsIcon,
val onClick: () -> Unit
) : PreferenceModel<Model>()
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
override fun bind(model: Model) {
super.bind(model)
itemView.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.views.LearnMoreTextView
object LegacyGroupPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_legacy_group_preference))
}
class Model(
val state: State,
val onLearnMoreClick: () -> Unit,
val onUpgradeClick: () -> Unit,
val onMmsWarningClick: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return state == newItem.state
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val groupInfoText: LearnMoreTextView = findViewById(R.id.manage_group_info_text)
override fun bind(model: Model) {
itemView.visibility = View.VISIBLE
groupInfoText.setLinkColor(ContextCompat.getColor(context, R.color.signal_text_primary))
when (model.state) {
State.LEARN_MORE -> {
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_learn_more)
groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() }
groupInfoText.setLearnMoreVisible(true)
}
State.UPGRADE -> {
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade)
groupInfoText.setOnLinkClickListener { model.onUpgradeClick() }
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group)
}
State.TOO_LARGE -> {
groupInfoText.text = context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().hardLimit - 1)
groupInfoText.setLearnMoreVisible(false)
}
State.MMS_WARNING -> {
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group)
groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() }
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_invite_now)
}
State.NONE -> itemView.visibility = View.GONE
}
}
}
enum class State {
LEARN_MORE,
UPGRADE,
TOO_LARGE,
MMS_WARNING,
NONE
}
}

View File

@@ -0,0 +1,65 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.view.View
import android.widget.TextView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* Renders a Recipient as a row item with an icon, avatar, status, and admin state
*/
object RecipientPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.group_recipient_list_item))
}
class Model(
val recipient: Recipient,
val isAdmin: Boolean = false,
val onClick: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
recipient.hasSameContent(newItem.recipient) &&
isAdmin == newItem.isAdmin
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.recipient_avatar)
private val name: TextView = itemView.findViewById(R.id.recipient_name)
private val about: TextView = itemView.findViewById(R.id.recipient_about)
private val admin: View = itemView.findViewById(R.id.admin)
override fun bind(model: Model) {
itemView.setOnClickListener { model.onClick() }
avatar.setRecipient(model.recipient)
name.text = if (model.recipient.isSelf) {
context.getString(R.string.Recipient_you)
} else {
model.recipient.getDisplayName(context)
}
val aboutText = model.recipient.combinedAboutAndEmoji
if (aboutText.isNullOrEmpty()) {
about.visibility = View.GONE
} else {
about.text = model.recipient.combinedAboutAndEmoji
about.visibility = View.VISIBLE
}
admin.visible = model.isAdmin
}
}
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.database.Cursor
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ThreadPhotoRailView
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.MappingViewHolder
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Renders the shared media photo rail.
*/
object SharedMediaPreference {
fun register(adapter: MappingAdapter) {
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.conversation_settings_shared_media))
}
class Model(
val mediaCursor: Cursor,
val mediaIds: List<Long>,
val onMediaRecordClick: (MediaDatabase.MediaRecord, Boolean) -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return super.areContentsTheSame(newItem) &&
mediaIds == newItem.mediaIds
}
}
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view)
override fun bind(model: Model) {
rail.setCursor(GlideApp.with(rail), model.mediaCursor)
rail.setListener {
model.onMediaRecordClick(it, ViewUtil.isLtr(rail))
}
}
}
}

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.DateUtils
import java.util.Locale
object Utils {
fun Long.formatMutedUntil(context: Context): String {
return if (this == Long.MAX_VALUE) {
context.getString(R.string.ConversationSettingsFragment__conversation_muted_forever)
} else {
context.getString(
R.string.ConversationSettingsFragment__conversation_muted_until_s,
DateUtils.getTimeString(context, Locale.getDefault(), this)
)
}
}
}

View File

@@ -0,0 +1,118 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.MuteDialog
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.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
) {
private val mentionLabels: Array<String> by lazy {
resources.getStringArray(R.array.SoundsAndNotificationsSettingsFragment__mention_labels)
}
private val viewModel: SoundsAndNotificationsSettingsViewModel by viewModels(
factoryProducer = {
val recipientId = SoundsAndNotificationsSettingsFragmentArgs.fromBundle(requireArguments()).recipientId
val repository = SoundsAndNotificationsSettingsRepository(requireContext())
SoundsAndNotificationsSettingsViewModel.Factory(recipientId, repository)
}
)
override fun bindAdapter(adapter: DSLSettingsAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
if (state.recipientId != Recipient.UNKNOWN.id) {
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
}
private fun getConfiguration(state: SoundsAndNotificationsSettingsState): DSLConfiguration {
return configure {
val muteSummary = if (state.muteUntil > 0) {
state.muteUntil.formatMutedUntil(requireContext())
} else {
getString(R.string.SoundsAndNotificationsSettingsFragment__not_muted)
}
val muteIcon = if (state.muteUntil > 0) {
R.drawable.ic_bell_disabled_24
} else {
R.drawable.ic_bell_24
}
clickPref(
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mute_notifications),
icon = DSLSettingsIcon.from(muteIcon),
summary = DSLSettingsText.from(muteSummary),
onClick = {
if (state.muteUntil <= 0) {
MuteDialog.show(requireContext(), viewModel::setMuteUntil)
} else {
MaterialAlertDialogBuilder(requireContext())
.setMessage(muteSummary)
.setPositiveButton(R.string.ConversationSettingsFragment__unmute) { dialog, _ ->
viewModel.unmute()
dialog.dismiss()
}
.setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
.show()
}
}
)
if (state.hasMentionsSupport) {
val mentionSelection = if (state.mentionSetting == RecipientDatabase.MentionSetting.ALWAYS_NOTIFY) {
0
} else {
1
}
radioListPref(
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__mentions),
icon = DSLSettingsIcon.from(R.drawable.ic_at_24),
selected = mentionSelection,
listItems = mentionLabels,
onSelected = {
viewModel.setMentionSetting(
if (it == 0) {
RecipientDatabase.MentionSetting.ALWAYS_NOTIFY
} else {
RecipientDatabase.MentionSetting.DO_NOT_NOTIFY
}
)
}
)
}
val customSoundSummary = if (state.hasCustomNotificationSettings) {
R.string.preferences_on
} else {
R.string.preferences_off
}
clickPref(
title = DSLSettingsText.from(R.string.SoundsAndNotificationsSettingsFragment__custom_notifications),
icon = DSLSettingsIcon.from(R.drawable.ic_speaker_24),
summary = DSLSettingsText.from(customSoundSummary),
onClick = {
CustomNotificationsDialogFragment.create(state.recipientId).show(parentFragmentManager, null)
}
)
}
}
}

View File

@@ -0,0 +1,37 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
class SoundsAndNotificationsSettingsRepository(private val context: Context) {
fun setMuteUntil(recipientId: RecipientId, muteUntil: Long) {
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getRecipientDatabase(context).setMuted(recipientId, muteUntil)
}
}
fun setMentionSetting(recipientId: RecipientId, mentionSetting: RecipientDatabase.MentionSetting) {
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getRecipientDatabase(context).setMentionSetting(recipientId, mentionSetting)
}
}
fun hasCustomNotificationSettings(recipientId: RecipientId, consumer: (Boolean) -> Unit) {
SignalExecutors.BOUNDED.execute {
val recipient = Recipient.resolved(recipientId)
consumer(
if (recipient.notificationChannel != null || !NotificationChannels.supported()) {
true
} else {
NotificationChannels.updateWithShortcutBasedChannel(context, recipient)
}
)
}
}
}

View File

@@ -0,0 +1,13 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
data class SoundsAndNotificationsSettingsState(
val recipientId: RecipientId = Recipient.UNKNOWN.id,
val muteUntil: Long = 0L,
val mentionSetting: RecipientDatabase.MentionSetting = RecipientDatabase.MentionSetting.DO_NOT_NOTIFY,
val hasCustomNotificationSettings: Boolean = false,
val hasMentionsSupport: Boolean = false
)

View File

@@ -0,0 +1,53 @@
package org.thoughtcrime.securesms.components.settings.conversation.sounds
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.database.RecipientDatabase
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 SoundsAndNotificationsSettingsViewModel(
private val recipientId: RecipientId,
private val repository: SoundsAndNotificationsSettingsRepository
) : ViewModel() {
private val store = Store(SoundsAndNotificationsSettingsState())
val state: LiveData<SoundsAndNotificationsSettingsState> = store.stateLiveData
init {
store.update(Recipient.live(recipientId).liveData) { recipient, state ->
state.copy(
recipientId = recipientId,
muteUntil = if (recipient.isMuted) recipient.muteUntil else 0L,
mentionSetting = recipient.mentionSetting,
hasMentionsSupport = recipient.isPushV2Group,
hasCustomNotificationSettings = recipient.notificationChannel != null || !NotificationChannels.supported()
)
}
}
fun setMuteUntil(muteUntil: Long) {
repository.setMuteUntil(recipientId, muteUntil)
}
fun unmute() {
repository.setMuteUntil(recipientId, 0L)
}
fun setMentionSetting(mentionSetting: RecipientDatabase.MentionSetting) {
repository.setMentionSetting(recipientId, mentionSetting)
}
class Factory(
private val recipientId: RecipientId,
private val repository: SoundsAndNotificationsSettingsRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(SoundsAndNotificationsSettingsViewModel(recipientId, repository)))
}
}
}

View File

@@ -1,13 +1,10 @@
package org.thoughtcrime.securesms.components.settings
import androidx.annotation.CallSuper
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.util.MappingModel
import org.thoughtcrime.securesms.util.MappingModelList
private const val UNSET = -1
fun configure(init: DSLConfiguration.() -> Unit): DSLConfiguration {
val configuration = DSLConfiguration()
configuration.init()
@@ -23,13 +20,24 @@ class DSLConfiguration {
fun radioListPref(
title: DSLSettingsText,
@DrawableRes iconId: Int = UNSET,
icon: DSLSettingsIcon? = null,
dialogTitle: DSLSettingsText = title,
isEnabled: Boolean = true,
listItems: Array<String>,
selected: Int,
confirmAction: Boolean = false,
onSelected: (Int) -> Unit
) {
val preference = RadioListPreference(title, iconId, isEnabled, listItems, selected, onSelected)
val preference = RadioListPreference(
title = title,
icon = icon,
isEnabled = isEnabled,
dialogTitle = dialogTitle,
listItems = listItems,
selected = selected,
confirmAction = confirmAction,
onSelected = onSelected
)
children.add(preference)
}
@@ -47,12 +55,12 @@ class DSLConfiguration {
fun switchPref(
title: DSLSettingsText,
summary: DSLSettingsText? = null,
@DrawableRes iconId: Int = UNSET,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
isChecked: Boolean,
onClick: () -> Unit
) {
val preference = SwitchPreference(title, summary, iconId, isEnabled, isChecked, onClick)
val preference = SwitchPreference(title, summary, icon, isEnabled, isChecked, onClick)
children.add(preference)
}
@@ -70,20 +78,20 @@ class DSLConfiguration {
fun clickPref(
title: DSLSettingsText,
summary: DSLSettingsText? = null,
@DrawableRes iconId: Int = UNSET,
icon: DSLSettingsIcon? = null,
isEnabled: Boolean = true,
onClick: () -> Unit
) {
val preference = ClickPreference(title, summary, iconId, isEnabled, onClick)
val preference = ClickPreference(title, summary, icon, isEnabled, onClick)
children.add(preference)
}
fun externalLinkPref(
title: DSLSettingsText,
@DrawableRes iconId: Int = UNSET,
icon: DSLSettingsIcon? = null,
@StringRes linkId: Int
) {
val preference = ExternalLinkPreference(title, iconId, linkId)
val preference = ExternalLinkPreference(title, icon, linkId)
children.add(preference)
}
@@ -116,8 +124,8 @@ class DSLConfiguration {
abstract class PreferenceModel<T : PreferenceModel<T>>(
open val title: DSLSettingsText? = null,
open val summary: DSLSettingsText? = null,
@DrawableRes open val iconId: Int = UNSET,
open val isEnabled: Boolean = true
open val icon: DSLSettingsIcon? = null,
open val isEnabled: Boolean = true,
) : MappingModel<T> {
override fun areItemsTheSame(newItem: T): Boolean {
return when {
@@ -131,7 +139,7 @@ abstract class PreferenceModel<T : PreferenceModel<T>>(
override fun areContentsTheSame(newItem: T): Boolean {
return areItemsTheSame(newItem) &&
newItem.summary == summary &&
newItem.iconId == iconId &&
newItem.icon == icon &&
newItem.isEnabled == isEnabled
}
}
@@ -147,12 +155,14 @@ class DividerPreference : PreferenceModel<DividerPreference>() {
class RadioListPreference(
override val title: DSLSettingsText,
@DrawableRes override val iconId: Int = UNSET,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean,
val dialogTitle: DSLSettingsText = title,
val listItems: Array<String>,
val selected: Int,
val onSelected: (Int) -> Unit
) : PreferenceModel<RadioListPreference>(title = title, iconId = iconId, isEnabled = isEnabled) {
val onSelected: (Int) -> Unit,
val confirmAction: Boolean = false
) : PreferenceModel<RadioListPreference>() {
override fun areContentsTheSame(newItem: RadioListPreference): Boolean {
return super.areContentsTheSame(newItem) && listItems.contentEquals(newItem.listItems) && selected == newItem.selected
@@ -176,11 +186,11 @@ class MultiSelectListPreference(
class SwitchPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
@DrawableRes override val iconId: Int = UNSET,
isEnabled: Boolean,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean,
val isChecked: Boolean,
val onClick: () -> Unit
) : PreferenceModel<SwitchPreference>(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled) {
) : PreferenceModel<SwitchPreference>() {
override fun areContentsTheSame(newItem: SwitchPreference): Boolean {
return super.areContentsTheSame(newItem) && isChecked == newItem.isChecked
}
@@ -201,15 +211,15 @@ class RadioPreference(
class ClickPreference(
override val title: DSLSettingsText,
override val summary: DSLSettingsText? = null,
@DrawableRes override val iconId: Int = UNSET,
isEnabled: Boolean = true,
override val icon: DSLSettingsIcon? = null,
override val isEnabled: Boolean = true,
val onClick: () -> Unit
) : PreferenceModel<ClickPreference>(title = title, summary = summary, iconId = iconId, isEnabled = isEnabled)
) : PreferenceModel<ClickPreference>()
class ExternalLinkPreference(
override val title: DSLSettingsText,
@DrawableRes override val iconId: Int,
override val icon: DSLSettingsIcon?,
@StringRes val linkId: Int
) : PreferenceModel<ExternalLinkPreference>(title = title, iconId = iconId)
) : PreferenceModel<ExternalLinkPreference>()
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>(title = title)
class SectionHeaderPreference(override val title: DSLSettingsText) : PreferenceModel<SectionHeaderPreference>()

View File

@@ -0,0 +1,34 @@
package org.thoughtcrime.securesms.components.voice
import android.net.Uri
import org.thoughtcrime.securesms.database.DraftDatabase
import java.lang.IllegalArgumentException
private const val SIZE = "size"
class VoiceNoteDraft(
val uri: Uri,
val size: Long
) {
companion object {
@JvmStatic
fun fromDraft(draft: DraftDatabase.Draft): VoiceNoteDraft {
if (draft.type != DraftDatabase.Draft.VOICE_NOTE) {
throw IllegalArgumentException()
}
val draftUri = Uri.parse(draft.value)
val uri: Uri = draftUri.buildUpon().clearQuery().build()
val size: Long = draftUri.getQueryParameter("size")!!.toLong()
return VoiceNoteDraft(uri, size)
}
}
fun asDraft(): DraftDatabase.Draft {
val draftUri = uri.buildUpon().appendQueryParameter(SIZE, size.toString())
return DraftDatabase.Draft(DraftDatabase.Draft.VOICE_NOTE, draftUri.build().toString())
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.voice;
import android.content.ComponentName;
import android.media.AudioManager;
import android.media.session.PlaybackState;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
@@ -9,19 +10,28 @@ import android.os.Looper;
import android.os.Message;
import android.os.RemoteException;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Objects;
@@ -35,16 +45,18 @@ import java.util.Objects;
*/
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
public static final String EXTRA_PROGRESS = "voice.note.playhead";
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
private MediaBrowserCompat mediaBrowser;
private AppCompatActivity activity;
private ProgressEventHandler progressEventHandler;
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private MediaBrowserCompat mediaBrowser;
private AppCompatActivity activity;
private ProgressEventHandler progressEventHandler;
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
@@ -56,12 +68,44 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
null);
activity.getLifecycle().addObserver(this);
voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> {
if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) {
VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType();
LiveRecipient sender = Recipient.live(message.getSenderId());
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
threadRecipient.getLiveDataResolved(),
(s, t) -> VoiceNoteMediaDescriptionCompatFactory.getTitle(activity, s, t, null));
return Transformations.map(name, displayName -> Optional.of(
new VoiceNotePlayerView.State(
playbackState.getUri(),
message.getMessageId(),
message.getThreadId(),
!playbackState.isPlaying(),
message.getSenderId(),
message.getThreadRecipientId(),
message.getMessagePosition(),
message.getTimestamp(),
displayName,
playbackState.getPlayheadPositionMillis(),
playbackState.getTrackDuration(),
playbackState.getSpeed())));
} else {
return new DefaultValueLiveData<>(Optional.absent());
}
});
}
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
return voiceNotePlaybackState;
}
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
return voiceNotePlayerViewState;
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
mediaBrowser.connect();
@@ -93,17 +137,29 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING;
}
private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) {
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED;
}
private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) {
return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED;
}
private @NonNull MediaControllerCompat getMediaController() {
return MediaControllerCompat.getMediaController(activity);
}
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
startPlayback(audioSlideUri, messageId, progress, false);
startPlayback(audioSlideUri, messageId, -1, progress, false);
}
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
startPlayback(audioSlideUri, messageId, progress, true);
startPlayback(audioSlideUri, messageId, -1, progress, true);
}
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
startPlayback(draftUri, -1, threadId, progress, true);
}
/**
@@ -115,7 +171,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
* @param progress The desired progress % to seek to.
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
*/
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, double progress, boolean singlePlayback) {
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
if (isCurrentTrack(audioSlideUri)) {
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
@@ -124,6 +180,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
} else {
Bundle extras = new Bundle();
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_THREAD_ID, threadId);
extras.putDouble(EXTRA_PROGRESS, progress);
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
@@ -170,6 +227,15 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
}
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
if (isCurrentTrack(audioSlideUri)) {
Bundle bundle = new Bundle();
bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed);
getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null);
}
}
private boolean isCurrentTrack(@NonNull Uri uri) {
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
@@ -198,6 +264,17 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
MediaControllerCompat.setMediaController(activity, mediaController);
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
if (newState != null) {
voiceNotePlaybackState.postValue(newState);
} else {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
}
mediaController.registerCallback(mediaControllerCompatCallback);
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
@@ -207,6 +284,107 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
}
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
return mediaMetadataCompat != null &&
mediaMetadataCompat.getDescription() != null &&
mediaMetadataCompat.getDescription().getMediaUri() != null;
}
private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController,
@NonNull MediaMetadataCompat mediaMetadataCompat,
@Nullable VoiceNotePlaybackState previousState)
{
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
Bundle extras = mediaController.getExtras();
float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f;
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
position = previousState.getPlayheadPositionMillis();
}
if (duration <= 0 && previousState.getTrackDuration() > 0) {
duration = previousState.getTrackDuration();
}
}
if (duration > 0 && position >= 0 && position <= duration) {
return new VoiceNotePlaybackState(mediaUri,
position,
duration,
autoReset,
speed,
isPlayerActive(mediaController.getPlaybackState()),
getClipType(mediaMetadataCompat.getBundle()));
} else {
return null;
}
}
private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController,
@Nullable VoiceNotePlaybackState previousState)
{
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (isPlayerActive(mediaController.getPlaybackState()) &&
canExtractPlaybackInformationFromMetadata(mediaMetadataCompat))
{
return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState);
} else if (isPlayerPaused(mediaController.getPlaybackState()) &&
mediaMetadataCompat != null)
{
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
if (previousState != null && position < duration) {
return previousState.asPaused();
} else {
return VoiceNotePlaybackState.NONE;
}
} else {
return VoiceNotePlaybackState.NONE;
}
}
private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) {
long messageId = -1L;
RecipientId senderId = RecipientId.UNKNOWN;
long messagePosition = -1L;
long threadId = -1L;
RecipientId threadRecipientId = RecipientId.UNKNOWN;
long timestamp = -1L;
if (mediaExtras != null) {
messageId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID, -1L);
messagePosition = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION, -1L);
threadId = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID, -1L);
timestamp = mediaExtras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
if (serializedSenderId != null) {
senderId = RecipientId.from(serializedSenderId);
}
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID);
if (serializedThreadRecipientId != null) {
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
}
}
if (messageId != -1L) {
return new VoiceNotePlaybackState.ClipType.Message(messageId,
senderId,
threadRecipientId,
messagePosition,
threadId,
timestamp);
} else {
return VoiceNotePlaybackState.ClipType.Draft.INSTANCE;
}
}
private static class ProgressEventHandler extends Handler {
private final MediaControllerCompat mediaController;
@@ -223,36 +401,14 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
@Override
public void handleMessage(@NonNull Message msg) {
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (isPlayerActive(mediaController.getPlaybackState()) &&
mediaMetadataCompat != null &&
mediaMetadataCompat.getDescription() != null &&
mediaMetadataCompat.getDescription().getMediaUri() != null)
{
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
VoiceNotePlaybackState previousState = voiceNotePlaybackState.getValue();
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
position = previousState.getPlayheadPositionMillis();
}
if (duration <= 0 && previousState.getTrackDuration() > 0) {
duration = previousState.getTrackDuration();
}
}
if (duration > 0 && position >= 0 && position <= duration) {
voiceNotePlaybackState.postValue(new VoiceNotePlaybackState(mediaUri, position, duration, autoReset));
}
if (newPlaybackState != null) {
voiceNotePlaybackState.postValue(newPlaybackState);
}
if (isPlayerActive(mediaController.getPlaybackState())) {
sendEmptyMessageDelayed(0, 50);
} else {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
}
}
@@ -264,6 +420,10 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
notifyProgressEventHandler();
} else {
clearProgressEventHandler();
if (isPlayerStopped(state)) {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
}
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.voice
interface VoiceNoteMediaControllerOwner {
val voiceNoteMediaController: VoiceNoteMediaController
}

View File

@@ -6,6 +6,7 @@ import android.os.Bundle;
import android.support.v4.media.MediaDescriptionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.logging.Log;
@@ -14,10 +15,10 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
import java.util.Objects;
@@ -34,23 +35,44 @@ class VoiceNoteMediaDescriptionCompatFactory {
public static final String EXTRA_THREAD_ID = "voice.note.extra.THREAD_ID";
public static final String EXTRA_COLOR = "voice.note.extra.COLOR";
public static final String EXTRA_MESSAGE_ID = "voice.note.extra.MESSAGE_ID";
public static final String EXTRA_MESSAGE_TIMESTAMP = "voice.note.extra.MESSAGE_TIMESTAMP";
private static final String TAG = Log.tag(VoiceNoteMediaDescriptionCompatFactory.class);
private VoiceNoteMediaDescriptionCompatFactory() {}
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
long threadId,
@NonNull Uri draftUri)
{
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
if (threadRecipient == null) {
threadRecipient = Recipient.UNKNOWN;
}
return buildMediaDescription(context,
threadRecipient,
Recipient.self(),
Recipient.self(),
0,
threadId,
-1,
System.currentTimeMillis(),
draftUri);
}
/**
* Build out a MediaDescriptionCompat for a given voice note. Expects to be run
* on a background thread.
*
* @param context Context.
* @param messageRecord The MessageRecord of the given voice note.
*
* @return A MediaDescriptionCompat with all the details the service expects.
*/
@WorkerThread
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
@NonNull MessageRecord messageRecord)
@Nullable static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
@NonNull MessageRecord messageRecord)
{
int startingPosition = DatabaseFactory.getMmsSmsDatabase(context)
.getMessagePositionInConversation(messageRecord.getThreadId(),
@@ -58,46 +80,87 @@ class VoiceNoteMediaDescriptionCompatFactory {
Recipient threadRecipient = Objects.requireNonNull(DatabaseFactory.getThreadDatabase(context)
.getRecipientForThreadId(messageRecord.getThreadId()));
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
Recipient sender = messageRecord.isOutgoing() ? Recipient.self() : messageRecord.getIndividualRecipient();
Recipient avatarRecipient = threadRecipient.isGroup() ? threadRecipient : sender;
AudioSlide audioSlide = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide();
if (audioSlide == null) {
Log.w(TAG, "Message does not have an audio slide. Can't play this voice note.");
return null;
}
Uri uri = audioSlide.getUri();
if (uri == null) {
Log.w(TAG, "Audio slide does not have a URI. Can't play this voice note.");
return null;
}
return buildMediaDescription(context,
threadRecipient,
avatarRecipient,
sender,
startingPosition,
messageRecord.getThreadId(),
messageRecord.getId(),
messageRecord.getDateReceived(),
uri);
}
private static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
@NonNull Recipient threadRecipient,
@NonNull Recipient avatarRecipient,
@NonNull Recipient sender,
int startingPosition,
long threadId,
long messageId,
long dateReceived,
@NonNull Uri audioUri)
{
Bundle extras = new Bundle();
extras.putString(EXTRA_THREAD_RECIPIENT_ID, threadRecipient.getId().serialize());
extras.putString(EXTRA_AVATAR_RECIPIENT_ID, avatarRecipient.getId().serialize());
extras.putString(EXTRA_INDIVIDUAL_RECIPIENT_ID, sender.getId().serialize());
extras.putLong(EXTRA_MESSAGE_POSITION, startingPosition);
extras.putLong(EXTRA_THREAD_ID, messageRecord.getThreadId());
extras.putLong(EXTRA_THREAD_ID, threadId);
extras.putLong(EXTRA_COLOR, threadRecipient.getChatColors().asSingleColor());
extras.putLong(EXTRA_MESSAGE_ID, messageRecord.getId());
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_MESSAGE_TIMESTAMP, dateReceived);
NotificationPrivacyPreference preference = SignalStore.settings().getMessageNotificationsPrivacy();
String title;
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
title = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
sender.getDisplayName(context),
threadRecipient.getDisplayName(context));
} else if (preference.isDisplayContact()) {
title = sender.getDisplayName(context);
} else {
title = context.getString(R.string.MessageNotifier_signal_message);
}
String title = getTitle(context, sender, threadRecipient, preference);
String subtitle = null;
if (preference.isDisplayContact()) {
subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(),
messageRecord.getDateReceived()));
dateReceived));
}
Uri uri = ((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide().getUri();
return new MediaDescriptionCompat.Builder()
.setMediaUri(uri)
.setMediaUri(audioUri)
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build();
}
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
NotificationPrivacyPreference preference;
if (notificationPrivacyPreference == null) {
preference = new NotificationPrivacyPreference("all");
} else {
preference = notificationPrivacyPreference;
}
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
return context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
sender.getDisplayName(context),
threadRecipient.getDisplayName(context));
} else if (preference.isDisplayContact()) {
return sender.getDisplayName(context);
} else {
return context.getString(R.string.MessageNotifier_signal_message);
}
}
}

View File

@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.components.voice
import android.os.Bundle
import android.os.ResultReceiver
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController
class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() {
override fun getCommands(): Array<String> {
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED)
}
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) {
if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) {
val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f
player.playbackParameters = PlaybackParameters(speed)
voiceNotePlaybackParameters.setSpeed(speed)
}
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components.voice;
import android.os.Bundle;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.PlaybackParameters;
import org.signal.core.util.logging.Log;
public final class VoiceNotePlaybackParameters {
private final MediaSessionCompat mediaSessionCompat;
VoiceNotePlaybackParameters(@NonNull MediaSessionCompat mediaSessionCompat) {
this.mediaSessionCompat = mediaSessionCompat;
}
@NonNull PlaybackParameters getParameters() {
float speed = getSpeed();
return new PlaybackParameters(speed);
}
void setSpeed(float speed) {
Bundle extras = new Bundle();
extras.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, speed);
mediaSessionCompat.setExtras(extras);
}
private float getSpeed() {
Bundle extras = mediaSessionCompat.getController().getExtras();
if (extras == null) {
return 1f;
} else {
return extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f);
}
}
}

View File

@@ -6,6 +6,7 @@ import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
@@ -19,10 +20,14 @@ import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.source.ConcatenatingMediaSource;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
@@ -33,6 +38,7 @@ import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
@@ -46,11 +52,12 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
public static final Uri NEXT_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-down.ogg");
public static final Uri END_URI = Uri.parse("file:///android_asset/sounds/state-change_confirm-up.ogg");
private final Context context;
private final SimpleExoPlayer player;
private final Context context;
private final SimpleExoPlayer player;
private final VoiceNoteQueueDataAdapter queueDataAdapter;
private final AttachmentMediaSourceFactory mediaSourceFactory;
private final ConcatenatingMediaSource dataSource;
private final ConcatenatingMediaSource dataSource;
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private boolean canLoadMore;
private Uri latestUri = Uri.EMPTY;
@@ -58,13 +65,15 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
VoiceNotePlaybackPreparer(@NonNull Context context,
@NonNull SimpleExoPlayer player,
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter,
@NonNull AttachmentMediaSourceFactory mediaSourceFactory)
@NonNull AttachmentMediaSourceFactory mediaSourceFactory,
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
{
this.context = context;
this.player = player;
this.queueDataAdapter = queueDataAdapter;
this.mediaSourceFactory = mediaSourceFactory;
this.dataSource = new ConcatenatingMediaSource();
this.dataSource = new ConcatenatingMediaSource();
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
}
@Override
@@ -92,6 +101,7 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
Log.d(TAG, "onPrepareFromUri: " + uri);
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
@@ -101,7 +111,11 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
SimpleTask.run(EXECUTOR,
() -> {
if (singlePlayback) {
return loadMediaDescriptionForSinglePlayback(messageId);
if (messageId != -1) {
return loadMediaDescriptionForSinglePlayback(messageId);
} else {
return loadMediaDescriptionForDraftPlayback(threadId, uri);
}
} else {
return loadMediaDescriptionsForConsecutivePlayback(messageId);
}
@@ -119,7 +133,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
@Override
public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) {
if (timeline.getWindowCount() >= window) {
player.setPlayWhenReady(false);
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
player.seekTo(window, (long) (player.getDuration() * progress));
player.setPlayWhenReady(true);
player.removeListener(this);
}
}
@@ -127,6 +144,10 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
player.prepare(dataSource);
canLoadMore = !singlePlayback;
} else if (Objects.equals(latestUri, uri)) {
Log.w(TAG, "Requested playback but no voice notes could be found.");
ThreadUtil.postToMain(() -> Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
.show());
}
});
}
@@ -249,21 +270,31 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
return Collections.emptyList();
}
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord));
MediaDescriptionCompat mediaDescriptionCompat = VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord);
if (mediaDescriptionCompat == null) {
return Collections.emptyList();
} else {
return Collections.singletonList(mediaDescriptionCompat);
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Could not find message.", e);
return Collections.emptyList();
}
}
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) {
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri));
}
@WorkerThread
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionsForConsecutivePlayback(long messageId) {
try {
List<MessageRecord> recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
return Stream.of(buildFilteredMessageRecordList(recordsAfter))
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
.toList();
return buildFilteredMessageRecordList(recordsAfter).stream()
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (NoSuchMessageException e) {
Log.w(TAG, "Could not find message.", e);
return Collections.emptyList();

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