Compare commits

..

159 Commits

Author SHA1 Message Date
Cody Henthorne
0dfa6aab09 Bump version to 5.23.1 2021-09-03 20:43:59 -04:00
Cody Henthorne
4b6dbac758 Updated language translations. 2021-09-03 20:38:17 -04:00
Cody Henthorne
b816f901a5 Fix test for mac. 2021-09-03 20:33:03 -04:00
Lucio Maciel
76d1490810 Adjust conversation list item height and name margin. 2021-09-03 20:19:56 -04:00
Cody Henthorne
f2ab0b6423 Initial work to support Change Number. 2021-09-03 20:19:56 -04:00
Lucio Maciel
e09d162c1e Update conversations list UI. 2021-09-03 20:19:55 -04:00
Greyson Parrelli
c84de8fa60 Add a cache for GIFs. 2021-09-03 20:19:55 -04:00
Greyson Parrelli
8e020c05f6 Improve IdentityDatabase e164 check. 2021-09-03 09:15:08 -04:00
Greyson Parrelli
8c9eb880cf Bump version to 5.23.0 2021-09-02 21:36:18 -04:00
Greyson Parrelli
d7ddd85a90 Updated language translations. 2021-09-02 21:35:27 -04:00
Alex Hart
7d994b2ae1 Set proper money separator when presenting custom amount string to user in MoneyView. 2021-09-02 21:24:54 -04:00
Alex Hart
664d6475d9 Refresh media selection and sending flow with a shiny new UX. 2021-09-02 21:24:54 -04:00
Greyson Parrelli
a940487611 Improve logging around rate-limiting. 2021-09-02 21:24:54 -04:00
Sgn-32
9f995d61f4 Fix padding for Payments icon and title. 2021-09-02 21:24:54 -04:00
Leonid Zavodnik
a6690e1bde Update exoplayer version to v2.15
Fixes #11547
2021-09-02 21:24:54 -04:00
Greyson Parrelli
d507df2e7e Increase max log size to 15mb. 2021-09-02 21:24:54 -04:00
Greyson Parrelli
fa26eb2017 Switch back to mainline SQLCipher with true WAL mode. 2021-09-02 21:24:54 -04:00
Greyson Parrelli
0b53ba8950 Improve MMS database insertion performance. 2021-09-02 21:24:54 -04:00
Greyson Parrelli
7447e2497b Default the retry receipt flag to true. 2021-09-02 21:24:54 -04:00
Greyson Parrelli
7ac83625d3 Add a write-through cache to the identity store. 2021-09-02 21:24:53 -04:00
Cody Henthorne
50dfe7bc25 Update Staging KBS values. 2021-09-02 21:24:53 -04:00
Cody Henthorne
8e32592218 Clarify networking call order during registration flow. 2021-09-02 21:24:53 -04:00
Lucio Maciel
a3d72fc06c Update message details UI. 2021-09-02 21:24:53 -04:00
Greyson Parrelli
f5a6d61362 Add support for granular conversation data changes. 2021-09-02 21:24:53 -04:00
Greyson Parrelli
bca2205945 Add measurements, improve MSL insert. 2021-09-02 21:24:53 -04:00
Alex Hart
1241f4c0e9 Enable MobileCoin in Germany, France, and Switzerland. 2021-09-02 21:24:53 -04:00
Graham Campbell
f6253ad0bb Corrected Google trademark notice 2021-09-02 21:24:53 -04:00
Lucio Maciel
083301185c Update verify identity UI. 2021-09-02 21:24:53 -04:00
Lucio Maciel
0273d0f285 Save receipt timestamps on sms/mms database. 2021-09-02 21:24:53 -04:00
Cody Henthorne
3dc1ce3353 Bump version to 5.22.7 2021-09-02 16:44:02 -04:00
Cody Henthorne
f8e077b824 Updated language translations. 2021-09-02 16:43:30 -04:00
Greyson Parrelli
aec2ca1d87 Update libsignal-client to 0.9.0 2021-09-02 11:21:15 -04:00
Cody Henthorne
6e7a18ea11 Bump version to 5.22.6 2021-09-01 12:55:04 -04:00
Cody Henthorne
fe54ec9d6c Updated language translations. 2021-09-01 12:49:23 -04:00
Greyson Parrelli
1819af3000 Fix possible crash when a contact merge results in no UUID.
After merging contacts, it's possible that we don't have a valid
UUID. We need to be careful not to use it.

Kind of a bummer, but the storage sync flow is currently the only flow
where we have this 'possibly not valid UUID'. In the future we should
probably use something else instead of a SignalServiceAddress to keep
that abstraction clean.
2021-09-01 10:46:42 -04:00
Cody Henthorne
3c177c4883 Bump version to 5.22.5 2021-08-31 10:18:33 -04:00
Cody Henthorne
2c871a36d0 Updated language translations. 2021-08-31 10:18:10 -04:00
Greyson Parrelli
6bde55f73b Only check remote registrationIds for active records. 2021-08-31 09:46:37 -04:00
Cody Henthorne
50b4e137b4 Bump version to 5.22.4 2021-08-30 20:43:11 -04:00
Cody Henthorne
4f6d39859c Updated language translations. 2021-08-30 20:38:20 -04:00
Greyson Parrelli
45a6894da1 Handle invalid registrationIds during sender key sends. 2021-08-30 20:32:41 -04:00
Alex Hart
f71accea06 Revert "Replace use of AlertDialog.Builder with MaterialAlertDialogBuilder."
This reverts commit 9232eb7c16.
2021-08-30 20:32:41 -04:00
Greyson Parrelli
32888fa00b Re-enabled converation list observation while a conversation is open.
It honestly doesn't feel great to not have this, because when you back
out to the conversation list you have to wait for it to update.

Right now this seems like the lesser of two evils.
2021-08-30 20:32:41 -04:00
Greyson Parrelli
eba3c55ec8 Fix issue where you couldn't delete a blocked announcement group. 2021-08-30 11:50:07 -04:00
Greyson Parrelli
21b82e291b Fix crash when building local e164-only contact record.
Fixes #11572
2021-08-30 10:03:18 -04:00
Alex Hart
8d9d84c4cc Add drawable padding to contact item. 2021-08-30 09:34:18 -03:00
Alex Hart
4c25264fbf Fix issue with conversation list invalidation. 2021-08-30 09:21:26 -03:00
Alex Hart
7410d664dd Bump version to 5.22.3 2021-08-27 14:43:38 -03:00
Alex Hart
c878ba3cdf Updated language translations. 2021-08-27 14:43:38 -03:00
Greyson Parrelli
97798a146f Fix issue where request banner overlapped admin-only banner. 2021-08-27 14:43:38 -03:00
Greyson Parrelli
7c134a6c9d Fix issue where group leave failed to send in announcement group. 2021-08-27 14:43:38 -03:00
Greyson Parrelli
08008629b3 Fix some issues around SignalServiceAddress creation. 2021-08-27 14:43:38 -03:00
Greyson Parrelli
a57adcb2b0 Remove identity store cache. 2021-08-27 14:43:38 -03:00
Alex Hart
7790cac0ee Invalidate conversation list when it is not newly started. 2021-08-27 14:43:38 -03:00
Alex Hart
349ad06c45 Fix crash when animation ends after onDestroyView. 2021-08-27 14:43:38 -03:00
Alex Hart
3a75d30732 Remove requireContext call from async runnable. 2021-08-27 09:10:54 -03:00
Alex Hart
b48d4f3ec2 Bump version to 5.22.2 2021-08-26 17:41:09 -03:00
Alex Hart
c92f36f9a8 Updated language translations. 2021-08-26 17:39:57 -03:00
Greyson Parrelli
faa36d417c Switch back to mainline SQLCipher. 2021-08-26 16:05:52 -04:00
Alex Hart
a2b6e003b6 Potential fix for bad contacts. 2021-08-26 16:42:40 -03:00
AsamK
406af58394 Use EmojiTextView to display group names in AvatarPreviewActivity. 2021-08-26 15:38:42 -03:00
Greyson Parrelli
bd72fc8464 fixup! Revert some database transaction changes. 2021-08-26 12:06:28 -04:00
Greyson Parrelli
05fb1a52d2 Revert some database transaction changes. 2021-08-26 11:59:45 -04:00
Greyson Parrelli
b21abb8e7e Fix crash during block list parsing. 2021-08-26 09:51:28 -04:00
Alex Hart
b41e602539 Add hasGroupsInCommon to Recipient content check. 2021-08-26 10:46:06 -03:00
Alex Hart
3f233ed39f Use AttachmentsV2 if the resumable upload link from V3 becomes corrupted. 2021-08-26 10:24:20 -03:00
Alex Hart
ade6f60e76 Skip attachment template if digest is null. 2021-08-26 10:14:12 -03:00
Greyson Parrelli
62d85e6878 Stop listening to database changes in conversation list when not visible. 2021-08-25 19:47:48 -04:00
Greyson Parrelli
4d985255a8 Fix deviceId log for retry receipts. 2021-08-25 19:33:50 -04:00
Alex Hart
fd3ef0f557 Bump version to 5.22.1 2021-08-25 17:20:48 -03:00
Alex Hart
7f30300cd4 Updated language translations. 2021-08-25 17:20:48 -03:00
Greyson Parrelli
0459d118a3 Enable sender key by default. 2021-08-25 17:20:48 -03:00
Lucio Maciel
c92f3b5dfd Fix theming on invite friends Activity. 2021-08-25 16:05:20 -03:00
Greyson Parrelli
ba4d1c9844 Add a failsafe to prevent non-admin sends in announcement groups. 2021-08-25 14:20:49 -04:00
Greyson Parrelli
8c3a0c1f9f Fix crash after a backup restore. 2021-08-25 13:56:22 -04:00
Greyson Parrelli
1dc2a35d83 Fix overlapping text for not-in-group and announcement-only. 2021-08-25 13:52:19 -04:00
Greyson Parrelli
0a67731830 Add a write-through cache to the identity store. 2021-08-25 13:39:59 -04:00
Greyson Parrelli
28d86886bd Update handling of invalid unknown fields. 2021-08-25 13:34:29 -04:00
Greyson Parrelli
b1fcea673a Allowing joining group calls in announcement groups. 2021-08-25 13:21:11 -04:00
Greyson Parrelli
eb5418787a Disable the reply action in announcement groups. 2021-08-25 13:19:52 -04:00
Cody Henthorne
adbda02aa4 Fix minor Group Call Ringing UI bugs. 2021-08-25 13:13:25 -04:00
Greyson Parrelli
307f47fa33 Prevent forwarding to announcement groups in new forward fragment. 2021-08-25 12:38:14 -04:00
Cody Henthorne
c1fb4f9421 Include urgency in opaque call message sends. 2021-08-25 09:28:16 -04:00
Ehren Kret
6179c087fb Update URL for reaching Signal chat server. 2021-08-24 17:41:09 -04:00
Alex Hart
ae2ba5d185 Bump version to 5.22.0 2021-08-24 16:59:09 -03:00
Alex Hart
91128be8f6 Updated language translations. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
8748056130 Inline the announcement groups flag. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
3c4e3cf048 Improve retrieval from the identity table. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
eb48ab1784 Disallow marking users as registered without a UUID. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
665d9e31f6 Separate thread updates into a job and other perf improvements. 2021-08-24 16:59:09 -03:00
Cody Henthorne
db7272730e Add Small Group Ringing support. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
5787a5f68a Improve conversion of Recipient to SignalServiceAddress. 2021-08-24 16:59:09 -03:00
Alex Hart
1a21cafe6c Remove multi-forward feature flag. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
7465818f44 Fix crash where we required a UUID from an unregistered user. 2021-08-24 16:59:09 -03:00
Lucio Maciel
62cb29fdb7 Update Invite friends screen UI. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
a85b08d9da Added an internal setting for disabling shake to report. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
b18c3ec1a9 Update filtered executor in LiveRecipientCache. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
29489a664e Fix issue where synced media messages weren't downloading.
There was race where the AttachmentDownloadJob was run during a
transaction, which meant that it might not be able to see the message
that was just inserted.

Gotta be more careful now with WAL mode.
2021-08-24 16:59:09 -03:00
Greyson Parrelli
dbb1e50d00 Migrate the identity table to be keyed off of libsignal IDs. 2021-08-24 16:59:09 -03:00
Greyson Parrelli
2068fa8041 Several sender key performance improvements.
- Remove extra unnecessary sync message
- Add a bulk session retrieval method
- Do the encrypt in a transaction
2021-08-24 16:59:09 -03:00
Cody Henthorne
194975d068 Fix lobby copy when another of your devices is solely already in the group call. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
b7a067e954 Use a more accurate starting point for message send timings. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
1e050915ef Clean up unmigrated groups after recipient merge. 2021-08-24 09:09:27 -03:00
Alex Hart
6a5c234408 Always recalculate shown items when we update menu state in multiselect. 2021-08-24 09:09:27 -03:00
Alex Hart
7a1122b3f7 Force ConversationItem to intercept all touch events when in multiselect mode. 2021-08-24 09:09:27 -03:00
Sgn-32
962d943a22 Pretty print phone numbers of blocked users in privacy settings. 2021-08-24 09:09:27 -03:00
Goldmaster
dbcc5d696d Update README.md copyright year and links.
add link to apk download from signals website in the readme

updated the copyright to the current year.
2021-08-24 09:09:27 -03:00
Sgn-32
9232eb7c16 Replace use of AlertDialog.Builder with MaterialAlertDialogBuilder. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
fc9b8f43dd Fix GV2 storage sync crash.
Fixes #11459
2021-08-24 09:09:27 -03:00
Lucio Maciel
5e8d74bc11 Fix lock screen issues. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
642d1984c4 Ensure all SignalServiceAddresses have UUIDs. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
0ab2100fa5 Update libsignal-client to 0.8.4 2021-08-24 09:09:27 -03:00
Greyson Parrelli
6618d696e4 Migrate the session table to be keyed off of libsignal IDs. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
c24dfdce34 Use a more readable method of listing selectable variants. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
214e994e90 Update to SQLCipher with true WAL support. 2021-08-24 09:09:27 -03:00
Greyson Parrelli
b904de5b50 Remove unused gradle code. 2021-08-24 09:07:54 -03:00
Ehren Kret
ad7c81ef4e Limit JCenter dependencies 2021-08-24 09:07:54 -03:00
Alex Hart
3e8b5cdb61 Bump version to 5.21.6 2021-08-23 15:49:52 -03:00
Alex Hart
6aea849a42 Updated language translations. 2021-08-23 15:49:01 -03:00
Cody Henthorne
cd0bf470a9 Fix applying default timer to first media message. 2021-08-23 10:18:42 -04:00
Greyson Parrelli
c615b14c51 Bump version to 5.21.5 2021-08-19 21:19:56 -04:00
Greyson Parrelli
28bf6d300e Updated language translations. 2021-08-19 21:19:56 -04:00
Greyson Parrelli
a1095f966c Do the account restore within a transaction. 2021-08-19 21:19:56 -04:00
Alex Hart
58a8902d4e Only finish action mode after forwards are sent. 2021-08-19 21:14:10 -04:00
Alex Hart
e582976293 Fix issue with bad multiselect inset. 2021-08-19 15:34:14 -03:00
Alex Hart
143110047d Change counter to consider only unique conversation messages in multiselect. 2021-08-19 15:22:21 -03:00
Alex Hart
c1324c7496 Fix check indicator covering update in multiselect. 2021-08-19 15:17:41 -03:00
Lucio Maciel
53eee2bd16 Fix timestamps with image+text. 2021-08-18 16:10:52 -03:00
Greyson Parrelli
86b1d104d9 Bump version to 5.21.4 2021-08-18 10:48:13 -04:00
Greyson Parrelli
d1d2376210 Updated language translations. 2021-08-18 10:48:13 -04:00
Alex Hart
7bede7e98a Fix issue where forwarded messages would show unlock icon. 2021-08-18 10:48:13 -04:00
Lucio Maciel
fec4a7692d Collapse timestamps on "deleted" messages. 2021-08-18 10:48:09 -04:00
Greyson Parrelli
b58cede072 Fix issue with date header ID generation.
We render based on the date received, but were generating the ID with
the date sent. This caused the potential for a weird caching bug that
could cause us to render the wrong date.
2021-08-18 10:01:33 -04:00
Alex Hart
199fb517b1 Fix dark theme coloring for forward bottom sheet. 2021-08-18 09:33:29 -03:00
Alex Hart
921addf4c8 Fix error with vertical translation of quote cutout projection. 2021-08-18 09:33:29 -03:00
Greyson Parrelli
61aa991d79 Increase toast duration for forward error messages. 2021-08-18 08:32:21 -04:00
Alex Hart
c1c95e1ae2 Disable predictive animation support on conversation layout manager. 2021-08-18 09:02:29 -03:00
Greyson Parrelli
f95a29b0d4 Bump version to 5.21.3 2021-08-17 20:15:01 -04:00
Greyson Parrelli
f7bb9c85af Updated language translations. 2021-08-17 20:14:35 -04:00
Greyson Parrelli
ae30e4070c Default retry respond max age to 14 days. 2021-08-17 20:14:35 -04:00
Lucio Maciel
9a67c60b4e Don't inline jumbomoji timestamps. 2021-08-17 19:04:59 -04:00
Cody Henthorne
e86b26bd11 Give call button text a bit more room and fix centering issue. 2021-08-17 16:46:05 -04:00
Lucio Maciel
e7c259b1e9 Adjust timestamp alignment. 2021-08-17 17:22:23 -03:00
Alex Hart
c65761a034 Fix several issues with multiforwarding.
* Better forwarding and animations.
* Handle audio with text.
* Increase max forwardable count to 32
* Onboarding dialog.
* Send forth link previews.
* Safety number support.
* Fix slide behaviour.
2021-08-17 16:15:09 -03:00
Alex Hart
0b37b0ee16 Fix crash with detached fragment. 2021-08-17 15:17:23 -03:00
Cody Henthorne
d76e58ce09 Fix crash when updating empty thread on failed send. 2021-08-17 10:58:57 -04:00
Lucio Maciel
2b366f8c9c Fix audio with text footer. 2021-08-17 11:09:22 -03:00
Greyson Parrelli
d43f7d6ad9 Bump version to 5.21.2 2021-08-16 21:22:09 -04:00
Greyson Parrelli
5b7932281e Updated language translations. 2021-08-16 21:18:15 -04:00
Lucio Maciel
0599f76ed5 Fix alignment issues for single line timestamps. 2021-08-16 20:50:33 -04:00
Niel Thiart
31e0f3edfb Fix Signal Direct Share Shortcuts not appearing in Android Sharesheet.
Fixes #11537
2021-08-16 20:50:33 -04:00
Alex Hart
17b568e6d1 Fix sticker forwarding. 2021-08-16 20:50:33 -04:00
Alex Hart
7c11962cb3 Fix custom notification vibration state. 2021-08-16 20:50:33 -04:00
Alex Hart
a7c4199192 Add proper pluralization to message send toast. 2021-08-16 12:00:19 -03:00
Alex Hart
8cb3909093 Disable multiforward send button after the user presses it. 2021-08-16 11:50:53 -03:00
Alex Hart
7480ea66ec Fix issue where a document with text would cause a crash and not be multiselectable. 2021-08-16 11:36:03 -03:00
Cody Henthorne
8e94ced7b6 Bump version to 5.21.1 2021-08-13 17:50:08 -04:00
Cody Henthorne
ffd86a96da Updated language translations. 2021-08-13 17:47:25 -04:00
Lucio Maciel
d4cabce876 Fix crash when getLayout() is null. 2021-08-13 18:39:06 -03:00
732 changed files with 27535 additions and 14055 deletions

View File

@@ -4,7 +4,7 @@ Signal is a messaging app for simple private communication with friends.
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
Currently available on the Play store.
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
@@ -59,8 +59,8 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2013-2020 Signal
Copyright 2013-2021 Signal
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
Google Play and the Google Play logo are trademarks of Google Inc.
Google Play and the Google Play logo are trademarks of Google LLC.

View File

@@ -1,7 +1,3 @@
import org.signal.signing.ApkSignerUtil
import java.security.MessageDigest
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
@@ -27,6 +23,12 @@ repositories {
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
}
}
maven {
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
content {
includeGroupByRegex "org\\.signal.*"
}
}
maven { // textdrawable
url 'https://dl.bintray.com/amulyakhare/maven'
content {
@@ -35,11 +37,23 @@ repositories {
}
google()
mavenCentral()
jcenter()
mavenLocal()
maven {
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
}
jcenter {
content {
includeVersion "com.google.android.exoplayer", "exoplayer-core", "2.9.1"
includeVersion "com.google.android.exoplayer", "exoplayer-ui", "2.9.1"
includeVersion "com.google.android.exoplayer", "extension-mediasession", "2.9.1"
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
includeVersion "com.amulyakhare", "com.amulyakhare.textdrawable", "1.0.1"
includeVersion "com.google.android", "flexbox", "0.3.0"
}
}
}
protobuf {
@@ -57,8 +71,8 @@ protobuf {
}
}
def canonicalVersionCode = 897
def canonicalVersionName = "5.21.0"
def canonicalVersionCode = 913
def canonicalVersionName = "5.23.1"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -69,6 +83,31 @@ def abiPostFix = ['universal' : 0,
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
def selectableVariants = [
'internalProdFlipper',
'internalProdPerf',
'internalProdRelease',
'internalStagingFlipper',
'internalStagingPerf',
'internalStagingRelease',
'nightlyProdFlipper',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingPerf',
'playProdDebug',
'playProdFlipper',
'playProdPerf',
'playProdRelease',
'playStagingDebug',
'playStagingFlipper',
'playStagingPerf',
'playStagingRelease',
'studyProdMock',
'studyProdPerf',
'websiteProdFlipper',
'websiteProdRelease',
]
android {
buildToolsVersion BUILD_TOOL_VERSION
compileSdkVersion COMPILE_SDK
@@ -110,7 +149,7 @@ android {
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
@@ -133,7 +172,7 @@ android {
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44}"
buildConfigField "int[]", "MOBILE_COIN_REGIONS", "new int[]{44,49,33,41}"
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
@@ -295,7 +334,7 @@ android {
applicationIdSuffix ".staging"
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
@@ -303,7 +342,7 @@ android {
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
"\"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982\", " +
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
@@ -339,16 +378,9 @@ android {
def distribution = variant.getFlavors().get(0).name
def environment = variant.getFlavors().get(1).name
def buildType = variant.buildType.name
def fullName = distribution + environment.capitalize() + buildType.capitalize()
if (distribution == 'study' && buildType != 'perf' && buildType != 'mock') {
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') {
if (!selectableVariants.contains(fullName)) {
variant.setIgnore(true)
}
}
@@ -387,12 +419,12 @@ dependencies {
implementation 'androidx.exifinterface:exifinterface:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.navigation:navigation-fragment:2.1.0'
implementation 'androidx.navigation:navigation-ui:2.1.0'
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0'
implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.1.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:2.3.1'
implementation 'androidx.lifecycle:lifecycle-common-java8:2.3.1'
implementation 'androidx.lifecycle:lifecycle-reactivestreams-ktx:2.3.1'
implementation "androidx.camera:camera-core:1.0.0-beta11"
implementation "androidx.camera:camera-camera2:1.0.0-beta11"
implementation "androidx.camera:camera-lifecycle:1.0.0-beta11"
@@ -411,9 +443,9 @@ dependencies {
implementation 'com.google.android.gms:play-services-maps:16.1.0'
implementation 'com.google.android.gms:play-services-auth:16.0.1'
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1'
implementation 'com.google.android.exoplayer:exoplayer-core:2.15.0'
implementation 'com.google.android.exoplayer:exoplayer-ui:2.15.0'
implementation 'com.google.android.exoplayer:extension-mediasession:2.15.0'
implementation 'org.conscrypt:conscrypt-android:2.0.0'
implementation 'org.signal:aesgcmprovider:0.0.3'
@@ -425,7 +457,7 @@ dependencies {
implementation project(':device-transfer')
implementation 'org.signal:zkgroup-android:0.7.0'
implementation 'org.whispersystems:signal-client-android:0.8.3'
implementation "org.whispersystems:signal-client-android:${LIBSIGNAL_CLIENT_VERSION}"
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
implementation('com.mobilecoin:android-sdk:1.1.0') {
@@ -434,7 +466,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.10.8'
implementation 'org.signal:ringrtc-android:2.11.1'
implementation "me.leolin:ShortcutBadger:1.1.22"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -477,7 +509,7 @@ dependencies {
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
implementation "net.zetetic:android-database-sqlcipher:4.4.3"
implementation 'org.signal:android-database-sqlcipher:4.4.3-S2'
implementation "androidx.sqlite:sqlite:2.1.0"
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
@@ -520,67 +552,6 @@ dependencyVerification {
configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath'
}
def assembleWebsiteDescriptor = { variant, file ->
if (file.exists()) {
MessageDigest md = MessageDigest.getInstance("SHA-256");
file.eachByte 4096, {bytes, size ->
md.update(bytes, 0, size);
}
String digest = md.digest().collect {String.format "%02x", it}.join();
String url = variant.productFlavors.get(0).ext.websiteUpdateUrl
String apkName = file.getName()
String descriptor = "{" +
"\"versionCode\" : ${canonicalVersionCode * postFixSize + abiPostFix['universal']}," +
"\"versionName\" : \"$canonicalVersionName\"," +
"\"sha256sum\" : \"$digest\"," +
"\"url\" : \"$url/$apkName\"" +
"}"
File descriptorFile = new File(file.getParent(), apkName.replace(".apk", ".json"))
descriptorFile.write(descriptor)
}
}
def signProductionRelease = { variant ->
variant.outputs.collect { output ->
String apkName = output.outputFile.name
File inputFile = new File(output.outputFile.path)
File outputFile = new File(output.outputFile.parent, apkName.replace('-unsigned', ''))
new ApkSignerUtil('sun.security.pkcs11.SunPKCS11',
'pkcs11.config',
'PKCS11',
'file:pkcs11.password').calculateSignature(inputFile.getAbsolutePath(),
outputFile.getAbsolutePath())
inputFile.delete()
outputFile
}
}
task signProductionPlayRelease {
doLast {
signProductionRelease(android.applicationVariants.find { (it.name == 'playProdRelease') })
}
}
task signProductionInternalRelease {
doLast {
signProductionRelease(android.applicationVariants.find { (it.name == 'internalProdRelease') })
}
}
task signProductionWebsiteRelease {
doLast {
def variant = android.applicationVariants.find { (it.name == 'websiteProdRelease') }
File signedRelease = signProductionRelease(variant).find { it.name.contains('universal') }
assembleWebsiteDescriptor(variant, signedRelease)
}
}
def getLastCommitTimestamp() {
if (!(new File('.git').exists())) {
return System.currentTimeMillis().toString()

View File

@@ -2,4 +2,7 @@
-keep class org.sqlite.database.** { *; }
-keep class net.sqlcipher.** { *; }
-dontwarn net.sqlcipher.**
-dontwarn net.sqlcipher.**
-keep class net.zetetic.** { *; }
-dontwarn net.zetetic.**

View File

@@ -11,9 +11,9 @@ import androidx.annotation.Nullable;
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
import com.facebook.flipper.plugins.databases.DatabaseDriver;
import net.sqlcipher.DatabaseUtils;
import net.sqlcipher.database.SQLiteDatabase;
import net.sqlcipher.database.SQLiteStatement;
import net.zetetic.database.DatabaseUtils;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteStatement;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;

View File

@@ -243,6 +243,9 @@
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher" />
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity android:name=".deeplinks.DeepLinkEntryActivity"
@@ -315,7 +318,8 @@
<activity android:name=".messagedetails.MessageDetailsActivity"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar" />
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
@@ -363,11 +367,11 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.MediaSendActivity"
android:theme="@style/TextSecure.FullScreenMedia"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTop"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediasend.v2.MediaSelectionActivity"
android:theme="@style/TextSecure.FullScreenMedia"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
android:launchMode="singleTop"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".PassphraseChangeActivity"
android:label="@string/AndroidManifest__change_passphrase"

View File

@@ -174,6 +174,11 @@ public final class SignalCameraView extends FrameLayout {
private void init(Context context, @Nullable AttributeSet attrs) {
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
// Begin custom signal code block
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
// End custom signal code block
mCameraModule = new SignalCameraXModule(this);
if (attrs != null) {

View File

@@ -222,17 +222,10 @@ final class SignalCameraXModule {
// End Signal Custom Code Block
Rational targetAspectRatio;
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
// End Signal Custom Code Block
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
} else {
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
// End Signal Custom Code Block
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
}
// Begin Signal Custom Code Block
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
// End Signal Custom Code Block
// Begin Signal Custom Code Block
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.whispersystems.signalservice.api.account.AccountAttributes;
public final class AppCapabilities {
@@ -12,12 +11,13 @@ public final class AppCapabilities {
private static final boolean GV2_CAPABLE = true;
private static final boolean GV1_MIGRATION = true;
private static final boolean ANNOUNCEMENT_GROUPS = true;
private static final boolean SENDER_KEY = true;
/**
* @param storageCapable Whether or not the user can use storage service. This is another way of
* asking if the user has set a Signal PIN or not.
*/
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, FeatureFlags.senderKey(), ANNOUNCEMENT_GROUPS);
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS);
}
}

View File

@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -171,6 +170,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
.addNonBlocking(EmojiSource::refresh)
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
.addPostRender(this::initializeExpiringMessageManager)
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))

View File

@@ -27,6 +27,7 @@ import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
@@ -71,12 +72,14 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
}
Toolbar toolbar = findViewById(R.id.toolbar);
ImageView avatar = findViewById(R.id.avatar);
Toolbar toolbar = findViewById(R.id.toolbar);
EmojiTextView title = findViewById(R.id.title);
ImageView avatar = findViewById(R.id.avatar);
setSupportActionBar(toolbar);
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
requireSupportActionBar().setDisplayShowTitleEnabled(false);
Context context = getApplicationContext();
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
@@ -122,7 +125,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
}
});
toolbar.setTitle(recipient.getDisplayName(context));
title.setText(recipient.getDisplayName(context));
});
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);

View File

@@ -1,8 +1,6 @@
package org.thoughtcrime.securesms;
import android.graphics.Point;
import android.net.Uri;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.NonNull;
@@ -12,7 +10,6 @@ import androidx.lifecycle.Observer;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
@@ -29,7 +26,6 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.List;
@@ -49,11 +45,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
boolean pulseMention,
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean canPlayInline,
@NonNull Colorizer colorizer);
ConversationMessage getConversationMessage();
@NonNull ConversationMessage getConversationMessage();
void setEventListener(@Nullable EventListener listener);

View File

@@ -698,6 +698,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
@Override
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
if (getView() == null || !requireView().isAttachedToWindow()) {
Log.w(TAG, "Fragment's view was detached before the animation completed.");
return;
}
if (view == chip && transitionType == LayoutTransition.APPEARING) {
chipGroup.getLayoutTransition().removeTransitionListener(this);
registerChipRecipientObserver(chip, recipient.live());

View File

@@ -3,9 +3,7 @@ package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.graphics.PorterDuff;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.view.View.OnClickListener;
@@ -14,12 +12,12 @@ import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.AnimRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
import org.thoughtcrime.securesms.components.ContactFilterView;
@@ -34,10 +32,8 @@ import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
@@ -48,14 +44,13 @@ import java.util.function.Consumer;
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
private ContactSelectionListFragment contactsFragment;
private EditText inviteText;
private ViewGroup smsSendFrame;
private Button smsSendButton;
private Animation slideInAnimation;
private Animation slideOutAnimation;
private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
private Toolbar primaryToolbar;
private ContactSelectionListFragment contactsFragment;
private EditText inviteText;
private ViewGroup smsSendFrame;
private Button smsSendButton;
private Animation slideInAnimation;
private Animation slideOutAnimation;
private final DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
@Override
protected void onPreCreate() {
@@ -83,7 +78,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
private void initializeAppBar() {
primaryToolbar = findViewById(R.id.toolbar);
final Toolbar primaryToolbar = findViewById(R.id.toolbar);
setSupportActionBar(primaryToolbar);
assert getSupportActionBar() != null;
@@ -97,9 +92,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
View shareButton = findViewById(R.id.share_button);
Button smsButton = findViewById(R.id.sms_button);
TextView shareText = findViewById(R.id.share_text);
View smsButton = findViewById(R.id.sms_button);
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
Toolbar smsToolbar = findViewById(R.id.sms_send_frame_toolbar);
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
inviteText = findViewById(R.id.invite_text);
@@ -121,15 +116,14 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
smsSendButton.setOnClickListener(new SmsSendClickListener());
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
smsToolbar.setNavigationIcon(R.drawable.ic_search_conversation_24);
if (Util.isDefaultSmsProvider(this)) {
shareButton.setOnClickListener(new ShareClickListener());
smsButton.setOnClickListener(new SmsClickListener());
} else {
shareButton.setVisibility(View.GONE);
smsButton.setOnClickListener(new ShareClickListener());
smsButton.setText(R.string.InviteActivity_share);
smsButton.setVisibility(View.GONE);
shareText.setText(R.string.InviteActivity_share);
shareButton.setOnClickListener(new ShareClickListener());
}
}
@@ -162,9 +156,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
private void updateSmsButtonText(int count) {
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
count,
count));
smsSendButton.setText(getResources().getString(R.string.InviteActivity_send_sms, count));
smsSendButton.setEnabled(count > 0);
}
@@ -176,43 +168,21 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
}
@Override public boolean onSupportNavigateUp() {
if (smsSendFrame.getVisibility() == View.VISIBLE) {
cancelSmsSelection();
return false;
} else {
return super.onSupportNavigateUp();
}
}
private void cancelSmsSelection() {
setPrimaryColorsToolbarNormal();
contactsFragment.reset();
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
}
private void setPrimaryColorsToolbarNormal() {
primaryToolbar.setBackgroundColor(0);
primaryToolbar.getNavigationIcon().setColorFilter(null);
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary));
if (Build.VERSION.SDK_INT >= 23) {
WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor));
getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor));
WindowUtil.setLightStatusBarFromTheme(this);
}
WindowUtil.setLightNavigationBarFromTheme(this);
}
private void setPrimaryColorsToolbarForSms() {
primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine));
primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN);
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title));
if (Build.VERSION.SDK_INT >= 23) {
WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine));
WindowUtil.clearLightStatusBar(getWindow());
}
if (Build.VERSION.SDK_INT >= 27) {
getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine));
WindowUtil.clearLightNavigationBar(getWindow());
}
}
private class ShareClickListener implements OnClickListener {
@Override
public void onClick(View v) {
@@ -231,7 +201,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
private class SmsClickListener implements OnClickListener {
@Override
public void onClick(View v) {
setPrimaryColorsToolbarForSms();
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
}
}
@@ -283,7 +252,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
Recipient recipient = Recipient.resolved(recipientId);
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
if (recipient.getContactUri() != null) {
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());

View File

@@ -35,7 +35,6 @@ import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.BounceInterpolator;
import android.view.animation.TranslateAnimation;
@@ -51,6 +50,7 @@ import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricManager.Authenticators;
import androidx.biometric.BiometricPrompt;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.AnimatingToggle;
@@ -98,13 +98,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
private boolean hadFailure;
private boolean alreadyShown;
private final Runnable resumeScreenLockRunnable = () -> {
resumeScreenLock(!alreadyShown);
alreadyShown = true;
};
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate()");
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
super.onCreate(savedInstanceState);
setContentView(R.layout.prompt_passphrase_activity);
@@ -129,11 +132,20 @@ public class PassphrasePromptActivity extends PassphraseActivity {
setLockTypeVisibility();
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
resumeScreenLock(!alreadyShown);
alreadyShown = true;
ThreadUtil.postToMain(resumeScreenLockRunnable);
}
hadFailure = false;
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
}
@Override
public void onPause() {
super.onPause();
ThreadUtil.cancelRunnableOnMain(resumeScreenLockRunnable);
biometricPrompt.cancelAuthentication();
}
@Override
@@ -388,9 +400,6 @@ public class PassphrasePromptActivity extends PassphraseActivity {
@Override
public void onAnimationEnd(Animator animation) {
handleAuthenticated();
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
}
}).start();
}
@@ -412,7 +421,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
@Override
public void onAnimationEnd(Animation animation) {
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
}
@Override

View File

@@ -45,30 +45,32 @@ import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnticipateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.view.animation.ScaleAnimation;
import android.widget.CompoundButton;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextSwitcher;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import androidx.core.view.OneShotPreDrawListener;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentTransaction;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
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.ShapeScrim;
import org.thoughtcrime.securesms.components.camera.CameraView;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
import org.thoughtcrime.securesms.permissions.Permissions;
@@ -115,7 +117,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
private final VerifyScanFragment scanFragment = new VerifyScanFragment();
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityDatabase.IdentityRecord identityRecord)
@NonNull IdentityRecord identityRecord)
{
return newIntent(context,
identityRecord.getRecipientId(),
@@ -124,7 +126,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
}
public static Intent newIntent(@NonNull Context context,
@NonNull IdentityDatabase.IdentityRecord identityRecord,
@NonNull IdentityRecord identityRecord,
boolean verified)
{
return newIntent(context,
@@ -214,7 +216,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
}
public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
public static class VerifyDisplayFragment extends Fragment {
public static final String RECIPIENT_ID = "recipient_id";
public static final String REMOTE_NUMBER = "remote_number";
@@ -230,23 +232,28 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
private View container;
private View numbersContainer;
private View loading;
private View qrCodeContainer;
private ImageView qrCode;
private ImageView qrVerified;
private TextView tapLabel;
private TextSwitcher tapLabel;
private TextView description;
private View.OnClickListener clickListener;
private SwitchCompat verified;
private Button verifyButton;
private TextView[] codes = new TextView[12];
private boolean animateSuccessOnDraw = false;
private boolean animateFailureOnDraw = false;
private boolean currentVerifiedState = false;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
this.numbersContainer = container.findViewById(R.id.number_table);
this.loading = container.findViewById(R.id.loading);
this.qrCodeContainer = container.findViewById(R.id.qr_code_container);
this.qrCode = container.findViewById(R.id.qr_code);
this.verified = container.findViewById(R.id.verified_switch);
this.verifyButton = container.findViewById(R.id.verify_button);
this.qrVerified = container.findViewById(R.id.qr_verified);
this.description = container.findViewById(R.id.description);
this.tapLabel = container.findViewById(R.id.tap_label);
@@ -263,11 +270,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
this.codes[10] = container.findViewById(R.id.code_eleventh);
this.codes[11] = container.findViewById(R.id.code_twelth);
this.qrCode.setOnClickListener(clickListener);
this.qrCodeContainer.setOnClickListener(clickListener);
this.registerForContextMenu(numbersContainer);
this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
this.verified.setOnCheckedChangeListener(this);
updateVerifyButton(getArguments().getBoolean(VERIFIED_STATE, false), false);
this.verifyButton.setOnClickListener((button -> updateVerifyButton(!currentVerifiedState, true)));
return container;
}
@@ -327,6 +334,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
@Override
protected void onPostExecute(Fingerprint fingerprint) {
if (getActivity() == null) return;
VerifyDisplayFragment.this.fingerprint = fingerprint;
setFingerprintViews(fingerprint, true);
getActivity().supportInvalidateOptionsMenu();
@@ -480,7 +488,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
}
private void setRecipientText(Recipient recipient) {
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
description.setMovementMethod(LinkMovementMethod.getInstance());
}
@@ -501,9 +509,11 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
if (animate) {
ViewUtil.fadeIn(qrCode, 1000);
ViewUtil.fadeIn(tapLabel, 1000);
ViewUtil.fadeOut(loading, 300, View.GONE);
} else {
qrCode.setVisibility(View.VISIBLE);
tapLabel.setVisibility(View.VISIBLE);
loading.setVisibility(View.GONE);
}
}
@@ -559,6 +569,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__successful_match));
animateVerified();
}
@@ -569,6 +581,8 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
qrVerified.setImageBitmap(qrSuccess);
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
tapLabel.setText(getString(R.string.verify_display_fragment__failed_to_verify_safety_number));
animateVerified();
}
@@ -576,7 +590,7 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
scaleAnimation.setInterpolator(new OvershootInterpolator());
scaleAnimation.setInterpolator(new FastOutSlowInInterpolator());
scaleAnimation.setDuration(800);
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
@@ -594,6 +608,9 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
scaleAnimation.setInterpolator(new AnticipateInterpolator());
scaleAnimation.setDuration(500);
ViewUtil.animateOut(qrVerified, scaleAnimation, View.GONE);
ViewUtil.fadeIn(qrCode, 800);
qrCodeContainer.setEnabled(true);
tapLabel.setText(getString(R.string.verify_display_fragment__tap_to_scan));
}
}, 2000);
}
@@ -602,53 +619,74 @@ public class VerifyIdentityActivity extends PassphraseRequiredActivity implement
public void onAnimationRepeat(Animation animation) {}
});
ViewUtil.fadeOut(qrCode, 200, View.INVISIBLE);
ViewUtil.animateIn(qrVerified, scaleAnimation);
qrCodeContainer.setEnabled(false);
}
@Override
public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) {
final Recipient recipient = this.recipient.get();
final RecipientId recipientId = recipient.getId();
private void updateVerifyButton(boolean verified, boolean update) {
currentVerifiedState = verified;
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (isChecked) {
Log.i(TAG, "Saving identity: " + recipientId);
DatabaseFactory.getIdentityDatabase(getActivity())
.saveIdentity(recipientId,
remoteIdentity,
VerifiedStatus.VERIFIED, false,
System.currentTimeMillis(), true);
} else {
DatabaseFactory.getIdentityDatabase(getActivity())
.setVerified(recipientId,
remoteIdentity,
VerifiedStatus.DEFAULT);
if (verified) {
verifyButton.setText(R.string.verify_display_fragment__clear_verification);
} else {
verifyButton.setText(R.string.verify_display_fragment__mark_as_verified);
}
if (update) {
final RecipientId recipientId = recipient.getId();
SignalExecutors.BOUNDED.execute(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
if (verified) {
Log.i(TAG, "Saving identity: " + recipientId);
ApplicationDependencies.getIdentityStore()
.saveIdentityWithoutSideEffects(recipientId,
remoteIdentity,
VerifiedStatus.VERIFIED,
false,
System.currentTimeMillis(),
true);
} else {
ApplicationDependencies.getIdentityStore().setVerified(recipientId, remoteIdentity, VerifiedStatus.DEFAULT);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
verified ? VerifiedStatus.VERIFIED
: VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(getActivity(), recipient.get(), verified, false);
}
ApplicationDependencies.getJobManager()
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
remoteIdentity,
isChecked ? VerifiedStatus.VERIFIED
: VerifiedStatus.DEFAULT));
StorageSyncHelper.scheduleSyncForDataChange();
IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
}
});
});
}
}
}
public static class VerifyScanFragment extends Fragment {
private View container;
private CameraView cameraView;
private ShapeScrim cameraScrim;
private ImageView cameraMarks;
private ScanningThread scanningThread;
private ScanListener scanListener;
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
this.cameraView = container.findViewById(R.id.scanner);
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
this.cameraView = container.findViewById(R.id.scanner);
this.cameraScrim = container.findViewById(R.id.camera_scrim);
this.cameraMarks = container.findViewById(R.id.camera_marks);
OneShotPreDrawListener.add(cameraScrim, () -> {
int width = cameraScrim.getScrimWidth();
int height = cameraScrim.getScrimHeight();
ViewUtil.updateLayoutParams(cameraMarks, width, height);
});
return container;
}

View File

@@ -17,6 +17,8 @@
package org.thoughtcrime.securesms;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
@@ -32,6 +34,7 @@ import android.os.Bundle;
import android.util.Rational;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
@@ -69,6 +72,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
@@ -82,8 +86,6 @@ import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
private static final String TAG = Log.tag(WebRtcCallActivity.class);
@@ -290,13 +292,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
viewModel.getOrientationAndLandscapeEnabled(),
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
.observe(this, p -> callScreen.updateCallParticipants(p));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
@@ -546,6 +550,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
}
public void handleGroupMemberCountChange(int count) {
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
callScreen.enableRingGroup(canRing);
ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
}
private void updateSpeakerHint(boolean showSpeakerHint) {
if (showSpeakerHint) {
callScreen.showSpeakerViewHint();
@@ -651,6 +661,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
callScreen.setRingGroup(event.shouldRingGroup());
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
}
}
}
@@ -765,6 +780,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public void onLocalPictureInPictureClicked() {
viewModel.onLocalPictureInPictureClicked();
}
@Override
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
if (ringingAllowed) {
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
} else {
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
}
}
}
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {

View File

@@ -57,6 +57,16 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
}
}
override fun onCancelEditing() {
Navigation.findNavController(requireView()).popBackStack()
}
override fun onMainImageLoaded() {
}
override fun onMainImageFailedToLoad() {
}
companion object {
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"

View File

@@ -15,6 +15,7 @@ import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.Navigation
import androidx.recyclerview.widget.RecyclerView
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.AvatarBundler
@@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.MappingAdapter
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Objects
/**
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
@@ -111,7 +111,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
putParcelable(SELECT_AVATAR_MEDIA, it)
}
)
Navigation.findNavController(v).popBackStack()
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
},
{
setFragmentResult(
@@ -120,7 +120,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
putBoolean(SELECT_AVATAR_CLEAR, true)
}
)
Navigation.findNavController(v).popBackStack()
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
}
)
}
@@ -149,7 +149,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
val media: Media = Objects.requireNonNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
viewModel.onAvatarPhotoSelectionCompleted(media)
} else {
super.onActivityResult(requestCode, resultCode, data)
@@ -195,23 +195,23 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
}
}
fun openPhotoEditor(photo: Avatar.Photo) {
private fun openPhotoEditor(photo: Avatar.Photo) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
}
fun openVectorEditor(vector: Avatar.Vector) {
private fun openVectorEditor(vector: Avatar.Vector) {
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
}
fun openTextEditor(text: Avatar.Text?) {
private fun openTextEditor(text: Avatar.Text?) {
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
Navigation.findNavController(requireView())
.navigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
}
fun openCameraCapture() {
private fun openCameraCapture() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
@@ -226,7 +226,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
.execute()
}
fun openGallery() {
private fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.ifNecessary()

View File

@@ -13,7 +13,7 @@ import androidx.documentfile.provider.DocumentFile;
import com.annimon.stream.function.Predicate;
import com.google.protobuf.ByteString;
import net.sqlcipher.database.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;

View File

@@ -11,7 +11,7 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import net.sqlcipher.database.SQLiteDatabase;
import net.zetetic.database.sqlcipher.SQLiteDatabase;
import org.greenrobot.eventbus.EventBus;
import org.signal.core.util.Conversions;

View File

@@ -13,6 +13,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Objects;
@@ -63,7 +64,7 @@ final class BlockedUsersAdapter extends ListAdapter<Recipient, BlockedUsersAdapt
displayName.setText(recipient.getDisplayName(itemView.getContext()));
if (recipient.hasAUserSetDisplayName(itemView.getContext())) {
String identifier = recipient.getE164().or(recipient.getUsername()).orNull();
String identifier = recipient.getE164().transform(PhoneNumberFormatter::prettyPrint).or(recipient.getUsername()).orNull();
if (identifier != null) {
numberOrUsername.setText(identifier);

View File

@@ -126,6 +126,11 @@ public final class ContactFilterView extends FrameLayout {
searchText.requestFocus();
}
int backgroundRes = attributes.getResourceId(R.styleable.ContactFilterToolbar_cfv_background, -1);
if (backgroundRes != -1) {
findViewById(R.id.background_holder).setBackgroundResource(backgroundRes);
}
attributes.recycle();
}

View File

@@ -215,6 +215,10 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
public TextView getDateView() {
return dateView;
}
private void notifyTouchDelegateChanged(@NonNull Rect rect, @NonNull View touchDelegate) {
if (onTouchDelegateChangedListener != null) {
onTouchDelegateChangedListener.onTouchDelegateChanged(rect, touchDelegate);

View File

@@ -38,7 +38,7 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_primary))
dialogBackground.setTint(ContextCompat.getColor(requireContext(), R.color.signal_background_dialog))
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {

View File

@@ -5,10 +5,14 @@ import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.style.CharacterStyle;
import android.text.style.MetricAffectingSpan;
import android.text.style.StyleSpan;
import android.text.style.TypefaceSpan;
import android.util.AttributeSet;
import androidx.annotation.Nullable;
@@ -17,15 +21,20 @@ 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.components.emoji.SimpleEmojiTextView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
public class FromTextView extends EmojiTextView {
public class FromTextView extends SimpleEmojiTextView {
private static final String TAG = Log.tag(FromTextView.class);
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
public FromTextView(Context context) {
super(context);
}
@@ -45,20 +54,9 @@ public class FromTextView extends EmojiTextView {
public void setText(Recipient recipient, boolean read, @Nullable String suffix) {
String fromString = recipient.getDisplayName(getContext());
int typeface;
if (!read) {
typeface = Typeface.BOLD;
} else {
typeface = Typeface.NORMAL;
}
SpannableStringBuilder builder = new SpannableStringBuilder();
SpannableString fromSpan = new SpannableString(fromString);
fromSpan.setSpan(new StyleSpan(typeface), 0, builder.length(),
Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
SpannableStringBuilder builder = new SpannableStringBuilder();
SpannableString fromSpan = new SpannableString(fromString);
fromSpan.setSpan(getFontSpan(!read), 0, fromSpan.length(), Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
if (recipient.isSelf()) {
builder.append(getContext().getString(R.string.note_to_self));
@@ -85,4 +83,8 @@ public class FromTextView extends EmojiTextView {
return mutedDrawable;
}
private CharacterStyle getFontSpan(boolean isBold) {
return isBold ? SpanUtil.getBoldSpan() : SpanUtil.getNormalSpan();
}
}

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import androidx.annotation.LayoutRes
import androidx.fragment.app.DialogFragment
import org.thoughtcrime.securesms.R
/**
* Fullscreen Dialog Fragment which will dismiss itself when the keyboard is closed
*/
abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
DialogFragment(contentLayoutId),
KeyboardAwareLinearLayout.OnKeyboardShownListener,
KeyboardAwareLinearLayout.OnKeyboardHiddenListener {
private var hasShown = false
override fun onCreate(savedInstanceState: Bundle?) {
setStyle(STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet)
super.onCreate(savedInstanceState)
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setDimAmount(0f)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
hasShown = false
val view = super.onCreateView(inflater, container, savedInstanceState)
return if (view is KeyboardAwareLinearLayout) {
view.addOnKeyboardShownListener(this)
view.addOnKeyboardHiddenListener(this)
view
} else {
throw IllegalStateException("Expected parent of view hierarchy to be keyboard aware.")
}
}
override fun onKeyboardShown() {
hasShown = true
}
override fun onKeyboardHidden() {
if (hasShown) {
dismissAllowingStateLoss()
}
}
}

View File

@@ -23,9 +23,12 @@ public class ShapeScrim extends View {
private final Paint eraser;
private final ShapeType shape;
private final float radius;
private final int canvasColor;
private Bitmap scrim;
private Canvas scrimCanvas;
private int scrimWidth;
private int scrimHeight;
public ShapeScrim(Context context) {
this(context, null);
@@ -57,13 +60,30 @@ public class ShapeScrim extends View {
this.eraser = new Paint();
this.eraser.setColor(0xFFFFFFFF);
this.eraser.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
this.canvasColor = Color.parseColor("#55BDBDBD");
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
super.onLayout(changed, l, t, r, b);
int shortDimension = Math.min(getWidth(), getHeight());
float drawRadius = shortDimension * radius;
float left = (getMeasuredWidth() / 2 ) - drawRadius;
float top = (getMeasuredHeight() / 2) - drawRadius;
float right = left + (drawRadius * 2);
float bottom = top + (drawRadius * 2);
scrimWidth = (int) (right - left);
scrimHeight = (int) (bottom - top);
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
int shortDimension = getWidth() < getHeight() ? getWidth() : getHeight();
int shortDimension = Math.min(getWidth(), getHeight());
float drawRadius = shortDimension * radius;
if (scrimCanvas == null) {
@@ -72,7 +92,7 @@ public class ShapeScrim extends View {
}
scrim.eraseColor(Color.TRANSPARENT);
scrimCanvas.drawColor(Color.parseColor("#55BDBDBD"));
scrimCanvas.drawColor(canvasColor);
if (shape == ShapeType.CIRCLE) drawCircle(scrimCanvas, drawRadius, eraser);
else drawSquare(scrimCanvas, drawRadius, eraser);
@@ -104,4 +124,12 @@ public class ShapeScrim extends View {
canvas.drawRoundRect(square, 25, 25, eraser);
}
public int getScrimWidth() {
return scrimWidth;
}
public int getScrimHeight() {
return scrimHeight;
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
@@ -23,7 +22,6 @@ import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.widget.TextViewCompat;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
@@ -54,6 +52,7 @@ public class EmojiTextView extends AppCompatTextView {
private boolean measureLastLine;
private int lastLineWidth = -1;
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private MentionRendererDelegate mentionRendererDelegate;
@@ -114,8 +113,10 @@ public class EmojiTextView extends AppCompatTextView {
if (emojis <= 4) scale += 0.25f;
if (emojis <= 2) scale += 0.25f;
isJumbomoji = scale > 1.0f;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize * scale);
} else if (scaleEmojis) {
isJumbomoji = false;
super.setTextSize(TypedValue.COMPLEX_UNIT_PX, originalFontSize);
}
@@ -153,7 +154,7 @@ public class EmojiTextView extends AppCompatTextView {
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
CharSequence text = getText();
if (!measureLastLine || text == null || text.length() == 0) {
if (getLayout() == null || !measureLastLine || text == null || text.length() == 0) {
lastLineWidth = -1;
} else {
Layout layout = getLayout();
@@ -175,7 +176,11 @@ public class EmojiTextView extends AppCompatTextView {
}
public boolean isSingleLine() {
return getLayout().getLineCount() == 1;
return getLayout() != null && getLayout().getLineCount() == 1;
}
public boolean isJumbomoji() {
return isJumbomoji;
}
public void setOverflowText(@Nullable CharSequence overflowText) {

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -20,6 +21,8 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment;
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment;
import java.util.Objects;
public class MediaKeyboard extends FrameLayout implements InputView {
private static final String TAG = Log.tag(MediaKeyboard.class);
@@ -40,6 +43,10 @@ public class MediaKeyboard extends FrameLayout implements InputView {
super(context, attrs);
}
public void setFragmentManager(@NonNull FragmentManager fragmentManager) {
this.fragmentManager = fragmentManager;
}
public void setKeyboardListener(@Nullable MediaKeyboardListener listener) {
this.keyboardListener = listener;
}
@@ -125,13 +132,32 @@ public class MediaKeyboard extends FrameLayout implements InputView {
private void initView() {
if (!isInitialised) {
LayoutInflater.from(getContext()).inflate(R.layout.media_keyboard, this, true);
if (fragmentManager == null) {
FragmentActivity activity = resolveActivity(getContext());
fragmentManager = activity.getSupportFragmentManager();
}
keyboardPagerFragment = new KeyboardPagerFragment();
fragmentManager.beginTransaction()
.replace(R.id.media_keyboard_fragment_container, keyboardPagerFragment)
.commitNowAllowingStateLoss();
keyboardState = State.NORMAL;
latestKeyboardHeight = -1;
isInitialised = true;
fragmentManager = ((FragmentActivity) getContext()).getSupportFragmentManager();
keyboardPagerFragment = (KeyboardPagerFragment) fragmentManager.findFragmentById(R.id.media_keyboard_fragment_container);
}
}
private static FragmentActivity resolveActivity(@Nullable Context context) {
if (context instanceof FragmentActivity) {
return (FragmentActivity) context;
} else if (context instanceof ContextThemeWrapper) {
return resolveActivity(((ContextThemeWrapper) context).getBaseContext());
} else {
throw new IllegalStateException("Could not locate FragmentActivity");
}
}

View File

@@ -7,7 +7,7 @@ import androidx.appcompat.widget.AppCompatTextView
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.libsignal.util.guava.Optional
open class SingleLineEmojiTextView @JvmOverloads constructor(
open class SimpleEmojiTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
@@ -15,20 +15,16 @@ open class SingleLineEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
init {
maxLines = 1
}
override fun setText(text: CharSequence?, type: BufferType?) {
bufferType = type
val candidates = if (isInEditMode) null else EmojiProvider.getCandidates(text)
if (SignalStore.settings().isPreferSystemEmoji || candidates == null || candidates.size() == 0) {
super.setText(Optional.fromNullable(text).or(""), BufferType.NORMAL)
} else {
val newContent = if (width == 0) {
val newContent = if (width == 0 || maxLines == -1) {
text
} else {
TextUtils.ellipsize(text, paint, width.toFloat(), TextUtils.TruncateAt.END, false, null)
TextUtils.ellipsize(text, paint, (width * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
val newCandidates = if (isInEditMode) null else EmojiProvider.getCandidates(newContent)
@@ -47,9 +43,4 @@ open class SingleLineEmojiTextView @JvmOverloads constructor(
setText(text, bufferType ?: BufferType.NORMAL)
}
}
override fun setMaxLines(maxLines: Int) {
check(maxLines == 1) { "setMaxLines: $maxLines != 1" }
super.setMaxLines(maxLines)
}
}

View File

@@ -9,9 +9,11 @@ import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
@@ -40,12 +42,12 @@ public class UntrustedSendDialog extends AlertDialog.Builder implements DialogIn
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
final TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
identityStore.setApproval(identityRecord.getRecipientId(), true);
}
}

View File

@@ -16,7 +16,7 @@ import androidx.annotation.RequiresApi;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import java.util.List;

View File

@@ -11,7 +11,9 @@ import org.thoughtcrime.securesms.R;
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;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.whispersystems.signalservice.api.SignalSessionLock;
import java.util.List;
@@ -39,27 +41,16 @@ public class UnverifiedSendDialog extends AlertDialog.Builder implements DialogI
@Override
public void onClick(DialogInterface dialog, int which) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(getContext());
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
SimpleTask.run(() -> {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : untrustedRecords) {
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
}
return null;
}
@Override
protected void onPostExecute(Void result) {
resendListener.onResendMessage();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return null;
}, nothing -> resendListener.onResendMessage());
}
public interface ResendListener {

View File

@@ -14,6 +14,11 @@ public class SmoothScrollingLinearLayoutManager extends LinearLayoutManager {
super(context, RecyclerView.VERTICAL, reverseLayout);
}
@Override
public boolean supportsPredictiveItemAnimations() {
return false;
}
public void smoothScrollToPosition(@NonNull Context context, int position, float millisecondsPerInch) {
final LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
@Override

View File

@@ -39,6 +39,7 @@ class AppSettingsActivity : DSLSettingsActivity() {
.setStartCategoryIndex(intent.getIntExtra(HelpFragment.START_CATEGORY_INDEX, 0))
StartLocation.PROXY -> AppSettingsFragmentDirections.actionDirectToEditProxyFragment()
StartLocation.NOTIFICATIONS -> AppSettingsFragmentDirections.actionDirectToNotificationsSettingsFragment()
StartLocation.CHANGE_NUMBER -> AppSettingsFragmentDirections.actionDirectToChangeNumberFragment()
}
}
@@ -98,6 +99,9 @@ class AppSettingsActivity : DSLSettingsActivity() {
@JvmStatic
fun notifications(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATIONS)
@JvmStatic
fun changeNumber(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CHANGE_NUMBER)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -110,7 +114,8 @@ class AppSettingsActivity : DSLSettingsActivity() {
BACKUPS(1),
HELP(2),
PROXY(3),
NOTIFICATIONS(4);
NOTIFICATIONS(4),
CHANGE_NUMBER(5);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.lock.v2.KbsConstants
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.pin.RegistrationLockV2Dialog
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ThemeUtil
@@ -103,6 +104,15 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
sectionHeaderPref(R.string.AccountSettingsFragment__account)
if (FeatureFlags.changeNumber()) {
clickPref(
title = DSLSettingsText.from(R.string.AccountSettingsFragment__change_phone_number),
onClick = {
Navigation.findNavController(requireView()).navigate(R.id.action_accountSettingsFragment_to_changePhoneNumberFragment)
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__transfer_account),
summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device),

View File

@@ -0,0 +1,27 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.BaseAccountLockedFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
class ChangeNumberAccountLockedFragment : BaseAccountLockedFragment(R.layout.fragment_change_number_account_locked) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
}
override fun getViewModel(): BaseRegistrationViewModel {
return ChangeNumberUtil.getViewModel(this)
}
override fun onNext() {
findNavController().navigateUp()
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
class ChangeNumberConfirmFragment : LoggingFragment(R.layout.fragment_change_number_confirm) {
private lateinit var viewModel: ChangeNumberViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel = ChangeNumberUtil.getViewModel(this)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
val confirmMessage: TextView = view.findViewById(R.id.change_number_confirm_new_number_message)
confirmMessage.text = getString(R.string.ChangeNumberConfirmFragment__you_are_about_to_change_your_phone_number_from_s_to_s, viewModel.oldNumberState.fullFormattedNumber, viewModel.number.fullFormattedNumber)
val newNumber: TextView = view.findViewById(R.id.change_number_confirm_new_number)
newNumber.text = viewModel.number.fullFormattedNumber
val editNumber: View = view.findViewById(R.id.change_number_confirm_edit_number)
editNumber.setOnClickListener { findNavController().navigateUp() }
val changeNumber: View = view.findViewById(R.id.change_number_confirm_change_number)
changeNumber.setOnClickListener { findNavController().navigate(R.id.action_changePhoneNumberConfirmFragment_to_changePhoneNumberVerifyFragment) }
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.fragments.BaseEnterCodeFragment
class ChangeNumberEnterCodeFragment : BaseEnterCodeFragment<ChangeNumberViewModel>(R.layout.fragment_change_number_enter_code) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.title = viewModel.number.fullFormattedNumber
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.verify_header).setOnClickListener(null)
}
override fun getViewModel(): ChangeNumberViewModel {
return getViewModel(this)
}
override fun handleSuccessfulVerify() {
displaySuccess { changeNumberSuccess() }
}
override fun navigateToCaptcha() {
findNavController().navigate(R.id.action_changeNumberEnterCodeFragment_to_captchaFragment, getCaptchaArguments())
}
override fun navigateToRegistrationLock(timeRemaining: Long) {
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberRegistrationLock(timeRemaining))
}
override fun navigateToKbsAccountLocked() {
findNavController().navigate(ChangeNumberEnterCodeFragmentDirections.actionChangeNumberEnterCodeFragmentToChangeNumberAccountLocked())
}
}

View File

@@ -0,0 +1,174 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.widget.ScrollView
import android.widget.Spinner
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.LabeledEditText
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberViewModel.ContinueStatus
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragment
import org.thoughtcrime.securesms.registration.fragments.CountryPickerFragmentArgs
import org.thoughtcrime.securesms.registration.util.RegistrationNumberInputController
import org.thoughtcrime.securesms.util.Dialogs
private const val OLD_NUMBER_COUNTRY_SELECT = "old_number_country"
private const val NEW_NUMBER_COUNTRY_SELECT = "new_number_country"
class ChangeNumberEnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_change_number_enter_phone_number) {
private lateinit var scrollView: ScrollView
private lateinit var oldNumberCountrySpinner: Spinner
private lateinit var oldNumberCountryCode: LabeledEditText
private lateinit var oldNumber: LabeledEditText
private lateinit var newNumberCountrySpinner: Spinner
private lateinit var newNumberCountryCode: LabeledEditText
private lateinit var newNumber: LabeledEditText
private lateinit var viewModel: ChangeNumberViewModel
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel = getViewModel(this)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.ChangeNumberEnterPhoneNumberFragment__change_number)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.change_number_enter_phone_number_continue).setOnClickListener {
onContinue()
}
scrollView = view.findViewById(R.id.change_number_enter_phone_number_scroll)
oldNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_old_number_spinner)
oldNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_old_number_country_code)
oldNumber = view.findViewById(R.id.change_number_enter_phone_number_old_number_number)
val oldController = RegistrationNumberInputController(
requireContext(),
oldNumberCountryCode,
oldNumber,
oldNumberCountrySpinner,
false,
object : RegistrationNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, oldNumber.bottom) }, 250)
}
override fun onNumberInputNext(view: View) {
newNumberCountryCode.requestFocus()
}
override fun onNumberInputDone(view: View) = Unit
override fun onPickCountry(view: View) {
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(OLD_NUMBER_COUNTRY_SELECT).build()
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
}
override fun setNationalNumber(number: String) {
viewModel.setOldNationalNumber(number)
}
override fun setCountry(countryCode: Int) {
viewModel.setOldCountry(countryCode)
}
}
)
newNumberCountrySpinner = view.findViewById(R.id.change_number_enter_phone_number_new_number_spinner)
newNumberCountryCode = view.findViewById(R.id.change_number_enter_phone_number_new_number_country_code)
newNumber = view.findViewById(R.id.change_number_enter_phone_number_new_number_number)
val newController = RegistrationNumberInputController(
requireContext(),
newNumberCountryCode,
newNumber,
newNumberCountrySpinner,
true,
object : RegistrationNumberInputController.Callbacks {
override fun onNumberFocused() {
scrollView.postDelayed({ scrollView.smoothScrollTo(0, newNumber.bottom) }, 250)
}
override fun onNumberInputNext(view: View) = Unit
override fun onNumberInputDone(view: View) {
onContinue()
}
override fun onPickCountry(view: View) {
val arguments: CountryPickerFragmentArgs = CountryPickerFragmentArgs.Builder().setResultKey(NEW_NUMBER_COUNTRY_SELECT).build()
findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_countryPickerFragment, arguments.toBundle())
}
override fun setNationalNumber(number: String) {
viewModel.setNewNationalNumber(number)
}
override fun setCountry(countryCode: Int) {
viewModel.setNewCountry(countryCode)
}
}
)
parentFragmentManager.setFragmentResultListener(OLD_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
viewModel.setOldCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
}
parentFragmentManager.setFragmentResultListener(NEW_NUMBER_COUNTRY_SELECT, this) { _, bundle ->
viewModel.setNewCountry(bundle.getInt(CountryPickerFragment.KEY_COUNTRY_CODE), bundle.getString(CountryPickerFragment.KEY_COUNTRY))
}
viewModel.getLiveOldNumber().observe(viewLifecycleOwner, oldController::updateNumber)
viewModel.getLiveNewNumber().observe(viewLifecycleOwner, newController::updateNumber)
}
private fun onContinue() {
if (TextUtils.isEmpty(oldNumberCountryCode.text)) {
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_number_country_code), Toast.LENGTH_LONG).show()
return
}
if (TextUtils.isEmpty(oldNumber.text)) {
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_old_phone_number), Toast.LENGTH_LONG).show()
return
}
if (TextUtils.isEmpty(newNumberCountryCode.text)) {
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_number_country_code), Toast.LENGTH_LONG).show()
return
}
if (TextUtils.isEmpty(newNumber.text)) {
Toast.makeText(context, getString(R.string.ChangeNumberEnterPhoneNumberFragment__you_must_specify_your_new_phone_number), Toast.LENGTH_LONG).show()
return
}
when (viewModel.canContinue()) {
ContinueStatus.CAN_CONTINUE -> findNavController().navigate(R.id.action_enterPhoneNumberChangeFragment_to_changePhoneNumberConfirmFragment)
ContinueStatus.INVALID_NUMBER -> {
Dialogs.showAlertDialog(
context, getString(R.string.RegistrationActivity_invalid_number), String.format(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), viewModel.number.e164Number)
)
}
ContinueStatus.OLD_NUMBER_DOESNT_MATCH -> {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.ChangeNumberEnterPhoneNumberFragment__the_phone_number_you_entered_doesnt_match_your_accounts)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
}

View File

@@ -0,0 +1,19 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
class ChangeNumberFragment : LoggingFragment(R.layout.fragment_change_phone_number) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
view.findViewById<View>(R.id.change_phone_number_continue).setOnClickListener {
findNavController().navigate(R.id.action_changePhoneNumberFragment_to_enterPhoneNumberChangeFragment)
}
}
}

View File

@@ -0,0 +1,43 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
class ChangeNumberPinDiffersFragment : LoggingFragment(R.layout.fragment_change_number_pin_differs) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<View>(R.id.change_number_pin_differs_keep_old_pin).setOnClickListener {
changeNumberSuccess()
}
val changePin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == CreateKbsPinActivity.RESULT_OK) {
changeNumberSuccess()
}
}
view.findViewById<View>(R.id.change_number_pin_differs_update_pin).setOnClickListener {
changePin.launch(CreateKbsPinActivity.getIntentForPinChangeFromSettings(requireContext()))
}
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.ChangeNumberPinDiffersFragment__keep_old_pin_question)
.setPositiveButton(android.R.string.ok) { _, _ -> changeNumberSuccess() }
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
)
}
}

View File

@@ -0,0 +1,63 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.PinHashing
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil.cancelSpinning
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
}
override fun getViewModel(): BaseRegistrationViewModel {
return ChangeNumberUtil.getViewModel(this)
}
override fun navigateToAccountLocked() {
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberAccountLocked())
}
override fun handleSuccessfulPinEntry(pin: String) {
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
cancelSpinning(pinButton)
if (pinsDiffer) {
findNavController().navigate(ChangeNumberRegistrationLockFragmentDirections.actionChangeNumberRegistrationLockToChangeNumberPinDiffers())
} else {
changeNumberSuccess()
}
}
override fun sendEmailToSupport() {
val subject = R.string.ChangeNumberRegistrationLockFragment__signal_change_number_need_help_with_pin_for_android_v2_pin
val body: String = SupportEmailUtil.generateSupportEmailBody(
requireContext(),
subject,
null,
null
)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body
)
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.content.Context
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.CertificateType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.KbsRepository
import org.thoughtcrime.securesms.pin.KeyBackupSystemWrongPinException
import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.KbsPinData
import org.whispersystems.signalservice.api.KeyBackupSystemNoDataException
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
private val TAG: String = Log.tag(ChangeNumberRepository::class.java)
class ChangeNumberRepository(private val context: Context) {
private val accountManager = ApplicationDependencies.getSignalServiceAccountManager()
fun changeNumber(code: String, newE164: String): Single<ServiceResponse<VerifyAccountResponse>> {
return Single.fromCallable { accountManager.changeNumber(code, newE164, null) }
.subscribeOn(Schedulers.io())
}
fun changeNumber(
code: String,
newE164: String,
pin: String,
tokenData: TokenData
): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
return Single.fromCallable {
try {
val kbsData: KbsPinData = KbsRepository.restoreMasterKey(pin, tokenData.enclave, tokenData.basicAuth, tokenData.tokenResponse)!!
val registrationLock: String = kbsData.masterKey.deriveRegistrationLock()
val response: ServiceResponse<VerifyAccountResponse> = accountManager.changeNumber(code, newE164, registrationLock)
VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse.from(response, kbsData)
} catch (e: KeyBackupSystemWrongPinException) {
ServiceResponse.forExecutionError(e)
} catch (e: KeyBackupSystemNoDataException) {
ServiceResponse.forExecutionError(e)
}
}.subscribeOn(Schedulers.io())
}
@WorkerThread
fun changeLocalNumber(e164: String): Single<Unit> {
TextSecurePreferences.setLocalNumber(context, e164)
DatabaseFactory.getRecipientDatabase(context).updateSelfPhone(e164)
ApplicationDependencies.closeConnections()
ApplicationDependencies.getIncomingMessageObserver()
return rotateCertificates()
}
@Suppress("UsePropertyAccessSyntax")
private fun rotateCertificates(): Single<Unit> {
val certificateTypes = SignalStore.phoneNumberPrivacy().allCertificateTypes
Log.i(TAG, "Rotating these certificates $certificateTypes")
return Single.fromCallable {
for (certificateType in certificateTypes) {
val certificate: ByteArray? = when (certificateType) {
CertificateType.UUID_AND_E164 -> accountManager.getSenderCertificate()
CertificateType.UUID_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
else -> throw AssertionError()
}
Log.i(TAG, "Successfully got $certificateType certificate")
SignalStore.certificateValues().setUnidentifiedAccessCertificate(certificateType, certificate)
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.CaptchaFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
/**
* Helpers for various aspects of the change number flow.
*/
object ChangeNumberUtil {
@JvmStatic
fun getViewModel(fragment: Fragment): ChangeNumberViewModel {
val navController = NavHostFragment.findNavController(fragment)
return ViewModelProvider(
navController.getViewModelStoreOwner(R.id.app_settings_change_number),
ChangeNumberViewModel.Factory(navController.getBackStackEntry(R.id.app_settings_change_number))
).get(ChangeNumberViewModel::class.java)
}
fun getCaptchaArguments(): Bundle {
return Bundle().apply {
putSerializable(
CaptchaFragment.EXTRA_VIEW_MODEL_PROVIDER,
object : CaptchaFragment.CaptchaViewModelProvider {
override fun get(fragment: CaptchaFragment): BaseRegistrationViewModel = getViewModel(fragment)
}
)
}
}
fun Fragment.changeNumberSuccess() {
findNavController().navigate(R.id.action_pop_app_settings_change_number)
Toast.makeText(requireContext(), R.string.ChangeNumber__your_phone_number_has_been_changed, Toast.LENGTH_SHORT).show()
}
}

View File

@@ -0,0 +1,75 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.os.Bundle
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.widget.Toolbar
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getCaptchaArguments
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.getViewModel
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.util.LifecycleDisposable
private val TAG: String = Log.tag(ChangeNumberVerifyFragment::class.java)
class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phone_number_verify) {
private lateinit var viewModel: ChangeNumberViewModel
private var requestingCaptcha: Boolean = false
private val lifecycleDisposable: LifecycleDisposable = LifecycleDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleDisposable.bindTo(lifecycle)
viewModel = getViewModel(this)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
toolbar.setTitle(R.string.ChangeNumberVerifyFragment__change_number)
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
val status: TextView = view.findViewById(R.id.change_phone_number_verify_status)
status.text = getString(R.string.ChangeNumberVerifyFragment__verifying_s, viewModel.number.fullFormattedNumber)
if (!requestingCaptcha || viewModel.hasCaptchaToken()) {
requestCode()
} else {
Toast.makeText(requireContext(), R.string.ChangeNumberVerifyFragment__captcha_required, Toast.LENGTH_SHORT).show()
findNavController().navigateUp()
}
}
private fun requestCode() {
lifecycleDisposable.add(
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { processor ->
if (processor.hasResult()) {
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.localRateLimit()) {
Log.i(TAG, "Unable to request sms code due to local rate limit")
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_changeNumberEnterCodeFragment)
} else if (processor.captchaRequired()) {
Log.i(TAG, "Unable to request sms code due to captcha required")
findNavController().navigate(R.id.action_changePhoneNumberVerifyFragment_to_captchaFragment, getCaptchaArguments())
requestingCaptcha = true
} else if (processor.rateLimit()) {
Log.i(TAG, "Unable to request sms code due to rate limit")
Toast.makeText(requireContext(), R.string.RegistrationActivity_rate_limited_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
} else {
Log.w(TAG, "Unable to request sms code", processor.error)
Toast.makeText(requireContext(), R.string.RegistrationActivity_unable_to_connect_to_service, Toast.LENGTH_LONG).show()
findNavController().navigateUp()
}
}
)
}
}

View File

@@ -0,0 +1,159 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import android.app.Application
import androidx.annotation.WorkerThread
import androidx.lifecycle.AbstractSavedStateViewModelFactory
import androidx.lifecycle.LiveData
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.savedstate.SavedStateRegistryOwner
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.pin.KbsRepository
import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor
import org.thoughtcrime.securesms.registration.VerifyAccountResponseWithoutKbs
import org.thoughtcrime.securesms.registration.VerifyCodeWithRegistrationLockResponseProcessor
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
private val TAG: String = Log.tag(ChangeNumberViewModel::class.java)
class ChangeNumberViewModel(
private val localNumber: String,
private val changeNumberRepository: ChangeNumberRepository,
savedState: SavedStateHandle,
password: String,
verifyAccountRepository: VerifyAccountRepository,
kbsRepository: KbsRepository,
) : BaseRegistrationViewModel(savedState, verifyAccountRepository, kbsRepository, password) {
var oldNumberState: NumberViewState = NumberViewState.Builder().build()
private set
private val liveOldNumberState = DefaultValueLiveData(oldNumberState)
private val liveNewNumberState = DefaultValueLiveData(number)
init {
try {
val countryCode: Int = PhoneNumberUtil.getInstance()
.parse(localNumber, null)
.countryCode
setOldCountry(countryCode)
setNewCountry(countryCode)
} catch (e: NumberParseException) {
Log.i(TAG, "Unable to parse number for default country code")
}
}
fun getLiveOldNumber(): LiveData<NumberViewState> {
return liveOldNumberState
}
fun getLiveNewNumber(): LiveData<NumberViewState> {
return liveNewNumberState
}
fun setOldNationalNumber(number: String) {
oldNumberState = oldNumberState.toBuilder()
.nationalNumber(number)
.build()
liveOldNumberState.value = oldNumberState
}
fun setOldCountry(countryCode: Int, country: String? = null) {
oldNumberState = oldNumberState.toBuilder()
.selectedCountryDisplayName(country)
.countryCode(countryCode)
.build()
liveOldNumberState.value = oldNumberState
}
fun setNewNationalNumber(number: String) {
setNationalNumber(number)
liveNewNumberState.value = this.number
}
fun setNewCountry(countryCode: Int, country: String? = null) {
onCountrySelected(country, countryCode)
liveNewNumberState.value = this.number
}
fun canContinue(): ContinueStatus {
return if (oldNumberState.e164Number == localNumber) {
if (number.isValid) {
ContinueStatus.CAN_CONTINUE
} else {
ContinueStatus.INVALID_NUMBER
}
} else {
ContinueStatus.OLD_NUMBER_DOESNT_MATCH
}
}
override fun verifyAccountWithoutRegistrationLock(): Single<ServiceResponse<VerifyAccountResponse>> {
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number)
}
override fun verifyAccountWithRegistrationLock(pin: String, kbsTokenData: TokenData): Single<ServiceResponse<VerifyAccountRepository.VerifyAccountWithRegistrationLockResponse>> {
return changeNumberRepository.changeNumber(textCodeEntered, number.e164Number, pin, kbsTokenData)
}
@WorkerThread
override fun onVerifySuccess(processor: VerifyAccountResponseProcessor): Single<VerifyAccountResponseProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number)
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)
VerifyAccountResponseWithoutKbs(ServiceResponse.forUnknownError(t))
}
}
override fun onVerifySuccessWithRegistrationLock(processor: VerifyCodeWithRegistrationLockResponseProcessor, pin: String): Single<VerifyCodeWithRegistrationLockResponseProcessor> {
return changeNumberRepository.changeLocalNumber(number.e164Number)
.map { processor }
.onErrorReturn { t ->
Log.w(TAG, "Error attempting to change local number", t)
VerifyCodeWithRegistrationLockResponseProcessor(ServiceResponse.forUnknownError(t), processor.token)
}
}
class Factory(owner: SavedStateRegistryOwner) : AbstractSavedStateViewModelFactory(owner, null) {
override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {
val context: Application = ApplicationDependencies.getApplication()
val localNumber: String = TextSecurePreferences.getLocalNumber(context)
val password: String = TextSecurePreferences.getPushServerPassword(context)
val viewModel = ChangeNumberViewModel(
localNumber = localNumber,
changeNumberRepository = ChangeNumberRepository(context),
savedState = handle,
password = password,
verifyAccountRepository = VerifyAccountRepository(context),
kbsRepository = KbsRepository()
)
return requireNotNull(modelClass.cast(viewModel))
}
}
enum class ContinueStatus {
CAN_CONTINUE,
INVALID_NUMBER,
OLD_NUMBER_DOESNT_MATCH
}
}

View File

@@ -74,8 +74,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
clickPref(
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_values_description),
title = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config),
summary = DSLSettingsText.from(R.string.preferences__internal_refresh_remote_config_description),
onClick = {
refreshRemoteValues()
}
@@ -83,7 +83,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
dividerPref()
sectionHeaderPref(R.string.preferences__internal_display)
sectionHeaderPref(R.string.preferences__internal_misc)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_user_details),
@@ -94,6 +94,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
switchPref(
title = DSLSettingsText.from(R.string.preferences__internal_shake_to_report),
summary = DSLSettingsText.from(R.string.preferences__internal_shake_to_report_description),
isChecked = state.shakeToReport,
onClick = {
viewModel.setShakeToReport(!state.shakeToReport)
}
)
dividerPref()
sectionHeaderPref(R.string.preferences__internal_storage_service)

View File

@@ -4,6 +4,7 @@ import org.thoughtcrime.securesms.emoji.EmojiFiles
data class InternalSettingsState(
val seeMoreUserDetails: Boolean,
val shakeToReport: Boolean,
val gv2doNotCreateGv2Groups: Boolean,
val gv2forceInvites: Boolean,
val gv2ignoreServerChanges: Boolean,

View File

@@ -25,6 +25,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setShakeToReport(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.SHAKE_TO_REPORT, enabled)
refresh()
}
fun setGv2DoNotCreateGv2Groups(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.GV2_DO_NOT_CREATE_GV2, enabled)
refresh()
@@ -86,6 +91,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
private fun getState() = InternalSettingsState(
seeMoreUserDetails = SignalStore.internalValues().recipientDetails(),
shakeToReport = SignalStore.internalValues().shakeToReport(),
gv2doNotCreateGv2Groups = SignalStore.internalValues().gv2DoNotCreateGv2Groups(),
gv2forceInvites = SignalStore.internalValues().gv2ForceInvites(),
gv2ignoreServerChanges = SignalStore.internalValues().gv2IgnoreServerChanges(),

View File

@@ -38,7 +38,7 @@ class ExpireTimerSettingsRepository(val context: Context) {
} else {
DatabaseFactory.getRecipientDatabase(context).setExpireMessages(recipientId, newExpirationTime)
val outgoingMessage = OutgoingExpirationUpdateMessage(Recipient.resolved(recipientId), System.currentTimeMillis(), newExpirationTime * 1000L)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null)
MessageSender.send(context, outgoingMessage, getThreadId(recipientId), false, null, null)
consumer.invoke(Result.success(newExpirationTime))
}
}

View File

@@ -11,8 +11,8 @@ 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.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
@@ -64,13 +64,9 @@ class ConversationSettingsRepository(
SignalExecutors.BOUNDED.execute { consumer(DatabaseFactory.getGroupDatabase(context).activeGroupCount > 0) }
}
fun getIdentity(recipientId: RecipientId, consumer: (IdentityDatabase.IdentityRecord?) -> Unit) {
fun getIdentity(recipientId: RecipientId, consumer: (IdentityRecord?) -> Unit) {
SignalExecutors.BOUNDED.execute {
consumer(
DatabaseFactory.getIdentityDatabase(context)
.getIdentity(recipientId)
.orNull()
)
consumer(ApplicationDependencies.getIdentityStore().getIdentityRecord(recipientId).orNull())
}
}
@@ -220,7 +216,14 @@ class ConversationSettingsRepository(
Preconditions.checkArgument(FeatureFlags.internalUser(), "Internal users only!")
SignalExecutors.BOUNDED.execute {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipientId)
val recipient = Recipient.resolved(recipientId)
if (recipient.hasUuid()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireUuid().toString())
}
if (recipient.hasE164()) {
DatabaseFactory.getSessionDatabase(context).deleteAllFor(recipient.requireE164())
}
}
}

View File

@@ -3,7 +3,7 @@ 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.database.model.IdentityRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
@@ -43,7 +43,7 @@ sealed class SpecificSettingsState {
abstract val isLoaded: Boolean
data class RecipientSettingsState(
val identityRecord: IdentityDatabase.IdentityRecord? = null,
val identityRecord: IdentityRecord? = null,
val allGroupsInCommon: List<Recipient> = listOf(),
val groupsInCommon: List<Recipient> = listOf(),
val selfHasGroups: Boolean = false,

View File

@@ -7,7 +7,6 @@ import org.thoughtcrime.securesms.groups.GroupAccessControl
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.LiveGroup
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.SingleLiveEvent
import org.thoughtcrime.securesms.util.livedata.Store
@@ -45,7 +44,7 @@ class PermissionsSettingsViewModel(
store.update(liveGroup.groupRecipient) { groupRecipient, state ->
val allHaveCapability = groupRecipient.participants.map { it.announcementGroupCapability }.all { it == Recipient.Capability.SUPPORTED }
state.copy(announcementGroupPermissionEnabled = (FeatureFlags.announcementGroups() && allHaveCapability) || state.announcementGroup)
state.copy(announcementGroupPermissionEnabled = allHaveCapability || state.announcementGroup)
}
}

View File

@@ -2,15 +2,12 @@ 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;
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;
@@ -45,9 +42,9 @@ 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_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);
@@ -77,7 +74,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
threadRecipient.getLiveDataResolved(),
(s, t) -> VoiceNoteMediaDescriptionCompatFactory.getTitle(activity, s, t, null));
(s, t) -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null));
return Transformations.map(name, displayName -> Optional.of(
new VoiceNotePlayerView.State(
@@ -262,32 +259,28 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback {
@Override
public void onConnected() {
try {
MediaSessionCompat.Token token = mediaBrowser.getSessionToken();
MediaControllerCompat mediaController = new MediaControllerCompat(activity, token);
MediaSessionCompat.Token token = mediaBrowser.getSessionToken();
MediaControllerCompat mediaController = new MediaControllerCompat(activity, token);
MediaControllerCompat.setMediaController(activity, mediaController);
MediaControllerCompat.setMediaController(activity, mediaController);
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
if (newState != null) {
voiceNotePlaybackState.postValue(newState);
} else {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
if (newState != null) {
voiceNotePlaybackState.postValue(newState);
} else {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
cleanUpOldProximityWakeLockManager();
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
mediaController.registerCallback(mediaControllerCompatCallback);
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
} catch (RemoteException e) {
Log.w(TAG, "onConnected: Failed to set media controller", e);
}
cleanUpOldProximityWakeLockManager();
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
mediaController.registerCallback(mediaControllerCompatCallback);
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
}
@Override
@@ -312,8 +305,8 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
}
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
return mediaMetadataCompat != null &&
mediaMetadataCompat.getDescription() != null &&
return mediaMetadataCompat != null &&
mediaMetadataCompat.getDescription() != null &&
mediaMetadataCompat.getDescription().getMediaUri() != null;
}
@@ -322,7 +315,7 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
@Nullable VoiceNotePlaybackState previousState)
{
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
boolean autoReset = Objects.equals(mediaUri, VoiceNotePlaybackPreparer.NEXT_URI) || Objects.equals(mediaUri, VoiceNotePlaybackPreparer.END_URI);
boolean autoReset = Objects.equals(mediaUri, VoiceNoteMediaItemFactory.NEXT_URI) || Objects.equals(mediaUri, VoiceNoteMediaItemFactory.END_URI);
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
Bundle extras = mediaController.getExtras();
@@ -384,17 +377,17 @@ public class VoiceNoteMediaController implements DefaultLifecycleObserver {
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);
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L);
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L);
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L);
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
if (serializedSenderId != null) {
senderId = RecipientId.from(serializedSenderId);
}
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID);
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
if (serializedThreadRecipientId != null) {
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
}

View File

@@ -9,6 +9,9 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.MediaMetadata;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -24,9 +27,9 @@ import java.util.Locale;
import java.util.Objects;
/**
* Factory responsible for building out MediaDescriptionCompat objects for voice notes.
* Factory responsible for building out MediaItem objects for voice notes.
*/
class VoiceNoteMediaDescriptionCompatFactory {
class VoiceNoteMediaItemFactory {
public static final String EXTRA_MESSAGE_POSITION = "voice.note.extra.MESSAGE_POSITION";
public static final String EXTRA_THREAD_RECIPIENT_ID = "voice.note.extra.RECIPIENT_ID";
@@ -37,13 +40,16 @@ class VoiceNoteMediaDescriptionCompatFactory {
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);
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 VoiceNoteMediaDescriptionCompatFactory() {}
private static final String TAG = Log.tag(VoiceNoteMediaItemFactory.class);
static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
long threadId,
@NonNull Uri draftUri)
private VoiceNoteMediaItemFactory() {}
static MediaItem buildMediaItem(@NonNull Context context,
long threadId,
@NonNull Uri draftUri)
{
Recipient threadRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(threadId);
@@ -51,28 +57,28 @@ class VoiceNoteMediaDescriptionCompatFactory {
threadRecipient = Recipient.UNKNOWN;
}
return buildMediaDescription(context,
threadRecipient,
Recipient.self(),
Recipient.self(),
0,
threadId,
-1,
System.currentTimeMillis(),
draftUri);
return buildMediaItem(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
* Build out a MediaItem 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.
* @return A MediaItem with all the details the service expects.
*/
@WorkerThread
@Nullable static MediaDescriptionCompat buildMediaDescription(@NonNull Context context,
@NonNull MessageRecord messageRecord)
@Nullable static MediaItem buildMediaItem(@NonNull Context context,
@NonNull MessageRecord messageRecord)
{
int startingPosition = DatabaseFactory.getMmsSmsDatabase(context)
.getMessagePositionInConversation(messageRecord.getThreadId(),
@@ -95,26 +101,26 @@ class VoiceNoteMediaDescriptionCompatFactory {
return null;
}
return buildMediaDescription(context,
threadRecipient,
avatarRecipient,
sender,
startingPosition,
messageRecord.getThreadId(),
messageRecord.getId(),
messageRecord.getDateReceived(),
uri);
return buildMediaItem(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)
private static MediaItem buildMediaItem(@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());
@@ -132,17 +138,28 @@ class VoiceNoteMediaDescriptionCompatFactory {
String subtitle = null;
if (preference.isDisplayContact()) {
subtitle = context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__voice_message,
subtitle = context.getString(R.string.VoiceNoteMediaItemFactory__voice_message,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(),
dateReceived));
}
return new MediaDescriptionCompat.Builder()
.setMediaUri(audioUri)
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build();
return new MediaItem.Builder()
.setUri(audioUri)
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build()
)
.setTag(
new MediaDescriptionCompat.Builder()
.setMediaUri(audioUri)
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build())
.build();
}
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
@@ -154,7 +171,7 @@ class VoiceNoteMediaDescriptionCompatFactory {
}
if (preference.isDisplayContact() && threadRecipient.isGroup()) {
return context.getString(R.string.VoiceNoteMediaDescriptionCompatFactory__s_to_s,
return context.getString(R.string.VoiceNoteMediaItemFactory__s_to_s,
sender.getDisplayName(context),
threadRecipient.getDisplayName(context));
} else if (preference.isDisplayContact()) {
@@ -163,4 +180,28 @@ class VoiceNoteMediaDescriptionCompatFactory {
return context.getString(R.string.MessageNotifier_signal_message);
}
}
public static MediaItem buildNextVoiceNoteMediaItem(@NonNull MediaItem source) {
return cloneMediaItem(source, "next", NEXT_URI);
}
public static MediaItem buildEndVoiceNoteMediaItem(@NonNull MediaItem source) {
return cloneMediaItem(source, "end", END_URI);
}
private static MediaItem cloneMediaItem(MediaItem source, String mediaId, Uri uri) {
MediaDescriptionCompat description = source.playbackProperties != null ? (MediaDescriptionCompat) source.playbackProperties.tag : null;
return source.buildUpon()
.setMediaId(mediaId)
.setUri(uri)
.setTag(
description != null ?
new MediaDescriptionCompat.Builder()
.setMediaUri(uri)
.setTitle(description.getTitle())
.setSubtitle(description.getSubtitle())
.setExtras(description.getExtras())
.build() : null)
.build();
}
}

View File

@@ -1,34 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultControlDispatcher;
import com.google.android.exoplayer2.Player;
public class VoiceNoteNotificationControlDispatcher extends DefaultControlDispatcher {
private final VoiceNoteQueueDataAdapter dataAdapter;
public VoiceNoteNotificationControlDispatcher(@NonNull VoiceNoteQueueDataAdapter dataAdapter) {
this.dataAdapter = dataAdapter;
}
@Override
public boolean dispatchSeekTo(Player player, int windowIndex, long positionMs) {
boolean isQueueToneIndex = windowIndex % 2 == 1;
boolean isSeekingToStart = positionMs == C.TIME_UNSET;
if (isQueueToneIndex && isSeekingToStart) {
int nextVoiceNoteWindowIndex = player.getCurrentWindowIndex() < windowIndex ? windowIndex + 1 : windowIndex - 1;
if (dataAdapter.size() <= nextVoiceNoteWindowIndex) {
return super.dispatchSeekTo(player, windowIndex, positionMs);
} else {
return super.dispatchSeekTo(player, nextVoiceNoteWindowIndex, positionMs);
}
} else {
return super.dispatchSeekTo(player, windowIndex, positionMs);
}
}
}

View File

@@ -4,7 +4,6 @@ import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.os.RemoteException;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
@@ -16,17 +15,13 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.contacts.avatars.ContactColors;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Objects;
@@ -40,30 +35,22 @@ class VoiceNoteNotificationManager {
VoiceNoteNotificationManager(@NonNull Context context,
@NonNull MediaSessionCompat.Token token,
@NonNull PlayerNotificationManager.NotificationListener listener,
@NonNull VoiceNoteQueueDataAdapter dataAdapter)
@NonNull PlayerNotificationManager.NotificationListener listener)
{
this.context = context;
try {
controller = new MediaControllerCompat(context, token);
} catch (RemoteException e) {
throw new IllegalArgumentException("Could not create a controller with given token");
}
notificationManager = PlayerNotificationManager.createWithNotificationChannel(context,
NotificationChannels.VOICE_NOTES,
R.string.NotificationChannel_voice_notes,
NOW_PLAYING_NOTIFICATION_ID,
new DescriptionAdapter());
this.context = context;
controller = new MediaControllerCompat(context, token);
notificationManager = new PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NotificationChannels.VOICE_NOTES)
.setChannelNameResourceId(R.string.NotificationChannel_voice_notes)
.setMediaDescriptionAdapter(new DescriptionAdapter())
.setNotificationListener(listener)
.build();
notificationManager.setMediaSessionToken(token);
notificationManager.setSmallIcon(R.drawable.ic_notification);
notificationManager.setRewindIncrementMs(0);
notificationManager.setFastForwardIncrementMs(0);
notificationManager.setNotificationListener(listener);
notificationManager.setColorized(true);
notificationManager.setControlDispatcher(new VoiceNoteNotificationControlDispatcher(dataAdapter));
notificationManager.setUseFastForwardAction(false);
notificationManager.setUseRewindAction(false);
notificationManager.setUseStopAction(true);
}
public void hideNotification() {
@@ -90,18 +77,20 @@ class VoiceNoteNotificationManager {
@Override
public @Nullable PendingIntent createCurrentContentIntent(Player player) {
if (!hasMetadata()) return null;
if (!hasMetadata()) {
return null;
}
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_RECIPIENT_ID);
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
if (serializedRecipientId == null) {
return null;
}
RecipientId recipientId = RecipientId.from(serializedRecipientId);
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_POSITION);
long threadId = controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_THREAD_ID);
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION);
long threadId = controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID);
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_COLOR);
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR);
if (color == 0) {
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor();
@@ -138,7 +127,7 @@ class VoiceNoteNotificationManager {
return null;
}
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_AVATAR_RECIPIENT_ID);
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID);
if (serializedRecipientId == null) {
return null;
}

View File

@@ -4,29 +4,31 @@ import android.media.AudioManager
import android.os.Bundle
import android.os.ResultReceiver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ControlDispatcher
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.SimpleExoPlayer
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.DefaultPlaybackController
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.util.Util
class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters) : DefaultPlaybackController() {
class VoiceNotePlaybackController(
private val player: SimpleExoPlayer,
private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters
) : MediaSessionConnector.CommandReceiver {
override fun getCommands(): Array<String> {
return arrayOf(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM)
}
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) {
@Suppress("deprecation")
override fun onCommand(p: Player, controlDispatcher: ControlDispatcher, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
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)
return true
} else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) {
val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC
val currentStreamType = Util.getStreamTypeForAudioUsage((player as SimpleExoPlayer).audioAttributes.usage)
val currentStreamType = Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
if (newStreamType != currentStreamType) {
val attributes = when (newStreamType) {
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
@@ -35,12 +37,14 @@ class VoiceNotePlaybackController(private val voiceNotePlaybackParameters: Voice
}
player.playWhenReady = false
player.audioAttributes = attributes
player.setAudioAttributes(attributes, false)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
}
}
return true
}
return false
}
}

View File

@@ -4,7 +4,6 @@ import android.content.Context;
import android.net.Uri;
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;
@@ -14,11 +13,12 @@ import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.ControlDispatcher;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.MediaMetadata;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
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;
@@ -26,12 +26,9 @@ 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;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import java.util.Collections;
import java.util.List;
@@ -49,30 +46,19 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
private static final long LIMIT = 5;
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 VoiceNoteQueueDataAdapter queueDataAdapter;
private final AttachmentMediaSourceFactory mediaSourceFactory;
private final ConcatenatingMediaSource dataSource;
private final Context context;
private final Player player;
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private boolean canLoadMore;
private Uri latestUri = Uri.EMPTY;
VoiceNotePlaybackPreparer(@NonNull Context context,
@NonNull SimpleExoPlayer player,
@NonNull VoiceNoteQueueDataAdapter queueDataAdapter,
@NonNull AttachmentMediaSourceFactory mediaSourceFactory,
@NonNull Player player,
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
{
this.context = context;
this.player = player;
this.queueDataAdapter = queueDataAdapter;
this.mediaSourceFactory = mediaSourceFactory;
this.dataSource = new ConcatenatingMediaSource();
this.context = context;
this.player = player;
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
}
@@ -82,23 +68,26 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
}
@Override
public void onPrepare() {
public void onPrepare(boolean playWhenReady) {
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepare");
}
@Override
public void onPrepareFromMediaId(String mediaId, Bundle extras) {
public void onPrepareFromMediaId(@NonNull String mediaId, boolean playWhenReady, @Nullable Bundle extras) {
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId");
}
@Override
public void onPrepareFromSearch(String query, Bundle extras) {
public void onPrepareFromSearch(@NonNull String query, boolean playWhenReady, @Nullable Bundle extras) {
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch");
}
@Override
public void onPrepareFromUri(final Uri uri, Bundle extras) {
public void onPrepareFromUri(@NonNull Uri uri, boolean playWhenReady, @Nullable Bundle extras) {
Log.d(TAG, "onPrepareFromUri: " + uri);
if (extras == null) {
return;
}
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
@@ -112,26 +101,25 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
() -> {
if (singlePlayback) {
if (messageId != -1) {
return loadMediaDescriptionForSinglePlayback(messageId);
return loadMediaItemsForSinglePlayback(messageId);
} else {
return loadMediaDescriptionForDraftPlayback(threadId, uri);
return loadMediaItemsForDraftPlayback(threadId, uri);
}
} else {
return loadMediaDescriptionsForConsecutivePlayback(messageId);
return loadMediaItemsForConsecutivePlayback(messageId);
}
},
descriptions -> {
queueDataAdapter.clear();
dataSource.clear();
mediaItems -> {
player.clearMediaItems();
if (Util.hasItems(descriptions) && Objects.equals(latestUri, uri)) {
applyDescriptionsToQueue(descriptions);
if (Util.hasItems(mediaItems) && Objects.equals(latestUri, uri)) {
applyDescriptionsToQueue(mediaItems);
int window = Math.max(0, queueDataAdapter.indexOf(uri));
int window = Math.max(0, indexOfPlayerMediaItemByUri(uri));
player.addListener(new Player.EventListener() {
player.addListener(new Player.Listener() {
@Override
public void onTimelineChanged(Timeline timeline, @Nullable Object manifest, int reason) {
public void onTimelineChanged(@NonNull Timeline timeline, int reason) {
if (timeline.getWindowCount() >= window) {
player.setPlayWhenReady(false);
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
@@ -142,102 +130,91 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
}
});
player.prepare(dataSource);
player.prepare();
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());
ThreadUtil.postToMain(() -> {
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
.show();
});
}
});
}
@Override
public String[] getCommands() {
return new String[0];
}
@Override
public void onCommand(Player player, String command, Bundle extras, ResultReceiver cb) {
}
@MainThread
private void applyDescriptionsToQueue(@NonNull List<MediaDescriptionCompat> descriptions) {
for (MediaDescriptionCompat description : descriptions) {
int holderIndex = queueDataAdapter.indexOf(description.getMediaUri());
MediaDescriptionCompat next = createNextClone(description);
int currentIndex = player.getCurrentWindowIndex();
private void applyDescriptionsToQueue(@NonNull List<MediaItem> mediaItems) {
for (MediaItem mediaItem : mediaItems) {
MediaItem.PlaybackProperties playbackProperties = mediaItem.playbackProperties;
if (playbackProperties == null) {
continue;
}
int holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri);
MediaItem next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem);
int currentIndex = player.getCurrentWindowIndex();
if (holderIndex != -1) {
queueDataAdapter.remove(holderIndex);
if (!queueDataAdapter.isEmpty()) {
queueDataAdapter.remove(holderIndex);
}
queueDataAdapter.add(holderIndex, createNextClone(description));
queueDataAdapter.add(holderIndex, description);
if (currentIndex != holderIndex) {
dataSource.removeMediaSource(holderIndex);
dataSource.addMediaSource(holderIndex, mediaSourceFactory.createMediaSource(description));
player.removeMediaItem(holderIndex);
player.addMediaItem(holderIndex, mediaItem);
}
if (currentIndex != holderIndex + 1) {
if (dataSource.getSize() > 1) {
dataSource.removeMediaSource(holderIndex + 1);
if (player.getMediaItemCount() > 1) {
player.removeMediaItem(holderIndex + 1);
}
dataSource.addMediaSource(holderIndex + 1, mediaSourceFactory.createMediaSource(next));
player.addMediaItem(holderIndex + 1, next);
}
} else {
int insertLocation = queueDataAdapter.indexAfter(description);
int insertLocation = indexAfter(mediaItem);
queueDataAdapter.add(insertLocation, next);
queueDataAdapter.add(insertLocation, description);
dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(next));
dataSource.addMediaSource(insertLocation, mediaSourceFactory.createMediaSource(description));
player.addMediaItem(insertLocation, next);
player.addMediaItem(insertLocation, mediaItem);
}
}
int lastIndex = queueDataAdapter.size() - 1;
MediaDescriptionCompat last = queueDataAdapter.getMediaDescription(lastIndex);
int itemsCount = player.getMediaItemCount();
if (itemsCount > 0) {
int lastIndex = itemsCount - 1;
MediaItem last = player.getMediaItemAt(lastIndex);
if (Objects.equals(last.getMediaUri(), NEXT_URI)) {
queueDataAdapter.remove(lastIndex);
dataSource.removeMediaSource(lastIndex);
if (last.playbackProperties != null &&
Objects.equals(last.playbackProperties.uri, VoiceNoteMediaItemFactory.NEXT_URI))
{
player.removeMediaItem(lastIndex);
if (queueDataAdapter.size() > 1) {
MediaDescriptionCompat end = createEndClone(last);
if (player.getMediaItemCount() > 1) {
MediaItem end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last);
queueDataAdapter.add(lastIndex, end);
dataSource.addMediaSource(lastIndex, mediaSourceFactory.createMediaSource(end));
player.addMediaItem(lastIndex, end);
}
}
}
}
if (queueDataAdapter.size() != dataSource.getSize()) {
throw new IllegalStateException("QueueDataAdapter and DataSource size inconsistency.");
private int indexOfPlayerMediaItemByUri(@NonNull Uri uri) {
for (int i = 0; i < player.getMediaItemCount(); i++) {
MediaItem.PlaybackProperties playbackProperties = player.getMediaItemAt(i).playbackProperties;
if (playbackProperties != null && playbackProperties.uri.equals(uri)) {
return i;
}
}
return -1;
}
private @NonNull MediaDescriptionCompat createEndClone(@NonNull MediaDescriptionCompat source) {
return buildUpon(source).setMediaId("end").setMediaUri(END_URI).build();
}
private int indexAfter(@NonNull MediaItem target) {
int size = player.getMediaItemCount();
long targetMessageId = target.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
for (int i = 0; i < size; i++) {
MediaMetadata mediaMetadata = player.getMediaItemAt(i).mediaMetadata;
long messageId = mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
private @NonNull MediaDescriptionCompat createNextClone(@NonNull MediaDescriptionCompat source) {
return buildUpon(source).setMediaId("next").setMediaUri(NEXT_URI).build();
}
private @NonNull MediaDescriptionCompat.Builder buildUpon(@NonNull MediaDescriptionCompat source) {
return new MediaDescriptionCompat.Builder()
.setSubtitle(source.getSubtitle())
.setDescription(source.getDescription())
.setTitle(source.getTitle())
.setIconUri(source.getIconUri())
.setIconBitmap(source.getIconBitmap())
.setMediaId(source.getMediaId())
.setExtras(source.getExtras());
if (messageId > targetMessageId) {
return i;
}
}
return size;
}
public void loadMoreVoiceNotes() {
@@ -245,36 +222,37 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
return;
}
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(player.getCurrentWindowIndex());
if (Objects.equals(mediaDescriptionCompat, VoiceNoteQueueDataAdapter.EMPTY)) {
MediaItem currentMediaItem = player.getCurrentMediaItem();
if (currentMediaItem == null) {
return;
}
long messageId = mediaDescriptionCompat.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID);
long messageId = currentMediaItem.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
SimpleTask.run(EXECUTOR,
() -> loadMediaDescriptionsForConsecutivePlayback(messageId),
descriptions -> {
if (Util.hasItems(descriptions) && canLoadMore) {
applyDescriptionsToQueue(descriptions);
() -> loadMediaItemsForConsecutivePlayback(messageId),
mediaItems -> {
if (Util.hasItems(mediaItems) && canLoadMore) {
applyDescriptionsToQueue(mediaItems);
}
});
}
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForSinglePlayback(long messageId) {
private @NonNull List<MediaItem> loadMediaItemsForSinglePlayback(long messageId) {
try {
MessageRecord messageRecord = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
MessageRecord messageRecord = DatabaseFactory.getMmsDatabase(context)
.getMessageRecord(messageId);
if (!MessageRecordUtil.hasAudio(messageRecord)) {
Log.w(TAG, "Message does not contain audio.");
return Collections.emptyList();
}
MediaDescriptionCompat mediaDescriptionCompat = VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context ,messageRecord);
if (mediaDescriptionCompat == null) {
MediaItem mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord);
if (mediaItem == null) {
return Collections.emptyList();
} else {
return Collections.singletonList(mediaDescriptionCompat);
return Collections.singletonList(mediaItem);
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Could not find message.", e);
@@ -282,17 +260,20 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
}
}
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionForDraftPlayback(long threadId, @NonNull Uri draftUri) {
return Collections.singletonList(VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, threadId, draftUri));
private @NonNull List<MediaItem> loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) {
return Collections
.singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri));
}
@WorkerThread
private @NonNull List<MediaDescriptionCompat> loadMediaDescriptionsForConsecutivePlayback(long messageId) {
private @NonNull List<MediaItem> loadMediaItemsForConsecutivePlayback(long messageId) {
try {
List<MessageRecord> recordsAfter = DatabaseFactory.getMmsSmsDatabase(context).getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
List<MessageRecord> recordsAfter = DatabaseFactory.getMmsSmsDatabase(context)
.getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
return buildFilteredMessageRecordList(recordsAfter).stream()
.map(record -> VoiceNoteMediaDescriptionCompatFactory.buildMediaDescription(context, record))
.map(record -> VoiceNoteMediaItemFactory
.buildMediaItem(context, record))
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (NoSuchMessageException e) {
@@ -306,4 +287,15 @@ final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackP
.takeWhile(MessageRecordUtil::hasAudio)
.toList();
}
@SuppressWarnings("deprecation")
@Override
public boolean onCommand(@NonNull Player player,
@NonNull ControlDispatcher controlDispatcher,
@NonNull String command,
@Nullable Bundle extras,
@Nullable ResultReceiver cb)
{
return false;
}
}

View File

@@ -6,11 +6,10 @@ import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Process;
import android.os.RemoteException;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
@@ -21,22 +20,15 @@ import androidx.core.content.ContextCompat;
import androidx.media.MediaBrowserServiceCompat;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultLoadControl;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.LoadControl;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
@@ -45,7 +37,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import java.util.Collections;
import java.util.List;
@@ -70,56 +61,38 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private MediaSessionCompat mediaSession;
private MediaSessionConnector mediaSessionConnector;
private PlaybackStateCompat.Builder stateBuilder;
private SimpleExoPlayer player;
private VoiceNotePlayer player;
private BecomingNoisyReceiver becomingNoisyReceiver;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNoteNotificationManager voiceNoteNotificationManager;
private VoiceNoteQueueDataAdapter queueDataAdapter;
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
private boolean isForegroundService;
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private final LoadControl loadControl = new DefaultLoadControl.Builder()
.setBufferDurationsMs(Integer.MAX_VALUE,
Integer.MAX_VALUE,
Integer.MAX_VALUE,
Integer.MAX_VALUE)
.createDefaultLoadControl();
@Override
public void onCreate() {
super.onCreate();
mediaSession = new MediaSessionCompat(this, TAG);
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
stateBuilder = new PlaybackStateCompat.Builder()
.setActions(SUPPORTED_ACTIONS)
.addCustomAction(ACTION_NEXT_PLAYBACK_SPEED, "speed", R.drawable.ic_toggle_24);
mediaSessionConnector = new MediaSessionConnector(mediaSession, new VoiceNotePlaybackController(voiceNotePlaybackParameters));
mediaSessionConnector = new MediaSessionConnector(mediaSession);
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
player = ExoPlayerFactory.newSimpleInstance(this, new DefaultRenderersFactory(this), new DefaultTrackSelector(), loadControl);
queueDataAdapter = new VoiceNoteQueueDataAdapter();
player = new VoiceNotePlayer(this);
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
mediaSession.getSessionToken(),
new VoiceNoteNotificationManagerListener(),
queueDataAdapter);
AttachmentMediaSourceFactory mediaSourceFactory = new AttachmentMediaSourceFactory(this);
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, queueDataAdapter, mediaSourceFactory, voiceNotePlaybackParameters);
mediaSession.setPlaybackState(stateBuilder.build());
new VoiceNoteNotificationManagerListener());
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, voiceNotePlaybackParameters);
player.addListener(new VoiceNotePlayerEventListener());
player.setAudioAttributes(new AudioAttributes.Builder()
.setContentType(C.CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_MEDIA)
.build(), true);
mediaSessionConnector.setPlayer(player, voiceNotePlaybackPreparer);
mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession, queueDataAdapter));
mediaSessionConnector.setPlayer(player);
mediaSessionConnector.setEnabledPlaybackActions(SUPPORTED_ACTIONS);
mediaSessionConnector.setPlaybackPreparer(voiceNotePlaybackPreparer);
mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession));
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
setSessionToken(mediaSession.getSessionToken());
@@ -131,7 +104,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
public void onTaskRemoved(Intent rootIntent) {
super.onTaskRemoved(rootIntent);
player.stop(true);
player.stop();
player.clearMediaItems();
}
@Override
@@ -158,10 +132,19 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
result.sendResult(Collections.emptyList());
}
private class VoiceNotePlayerEventListener implements Player.EventListener {
private class VoiceNotePlayerEventListener implements Player.Listener {
@Override
public void onPlayerStateChanged(boolean playWhenReady, int playbackState) {
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
onPlaybackStateChanged(playWhenReady, player.getPlaybackState());
}
@Override
public void onPlaybackStateChanged(int playbackState) {
onPlaybackStateChanged(player.getPlayWhenReady(), playbackState);
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
switch (playbackState) {
case Player.STATE_BUFFERING:
case Player.STATE_READY:
@@ -169,6 +152,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
if (!playWhenReady) {
stopForeground(false);
isForegroundService = false;
becomingNoisyReceiver.unregister();
} else {
sendViewedReceiptForCurrentWindowIndex();
@@ -182,30 +166,34 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
@Override
public void onPositionDiscontinuity(int reason) {
int currentWindowIndex = player.getCurrentWindowIndex();
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, int reason) {
int currentWindowIndex = newPosition.windowIndex;
if (currentWindowIndex == C.INDEX_UNSET) {
return;
}
if (reason == Player.DISCONTINUITY_REASON_PERIOD_TRANSITION) {
if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) {
sendViewedReceiptForCurrentWindowIndex();
MediaDescriptionCompat mediaDescriptionCompat = queueDataAdapter.getMediaDescription(currentWindowIndex);
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + mediaDescriptionCompat.getMediaUri());
MediaItem currentMediaItem = player.getCurrentMediaItem();
if (currentMediaItem != null && currentMediaItem.playbackProperties != null) {
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + currentMediaItem.playbackProperties.uri);
}
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex);
final float speed = playbackParameters != null ? playbackParameters.speed : 1f;
if (speed != player.getPlaybackParameters().speed) {
player.setPlayWhenReady(false);
player.setPlaybackParameters(playbackParameters);
if (playbackParameters != null) {
player.setPlaybackParameters(playbackParameters);
}
player.seekTo(currentWindowIndex, 1);
player.setPlayWhenReady(true);
}
}
boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD ||
currentWindowIndex + LOAD_MORE_THRESHOLD >= queueDataAdapter.size();
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
@@ -213,7 +201,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
@Override
public void onPlayerError(ExoPlaybackException error) {
public void onPlayerError(@NonNull PlaybackException error) {
Log.w(TAG, "ExoPlayer error occurred:", error);
}
}
@@ -236,16 +224,23 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
player.getCurrentWindowIndex() != C.INDEX_UNSET)
{
final MediaDescriptionCompat descriptionCompat = queueDataAdapter.getMediaDescription(player.getCurrentWindowIndex());
MediaItem currentMediaItem = player.getCurrentMediaItem();
if (currentMediaItem == null || currentMediaItem.playbackProperties == null) {
return;
}
if (!descriptionCompat.getMediaUri().getScheme().equals("content")) {
Uri mediaUri = currentMediaItem.playbackProperties.uri;
if (!mediaUri.getScheme().equals("content")) {
return;
}
SignalExecutors.BOUNDED.execute(() -> {
Bundle extras = descriptionCompat.getExtras();
long messageId = extras.getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID);
RecipientId recipientId = RecipientId.from(extras.getString(VoiceNoteMediaDescriptionCompatFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID));
Bundle extras = currentMediaItem.mediaMetadata.extras;
if (extras == null) {
return;
}
long messageId = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
RecipientId recipientId = RecipientId.from(extras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID));
MessageDatabase messageDatabase = DatabaseFactory.getMmsDatabase(this);
MessageDatabase.MarkedMessageInfo markedMessageInfo = messageDatabase.setIncomingMessageViewed(messageId);
@@ -264,8 +259,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener {
@Override
public void onNotificationStarted(int notificationId, Notification notification) {
if (!isForegroundService) {
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
if (ongoing && !isForegroundService) {
ContextCompat.startForegroundService(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
startForeground(notificationId, notification);
isForegroundService = true;
@@ -273,7 +268,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
@Override
public void onNotificationCancelled(int notificationId) {
public void onNotificationCancelled(int notificationId, boolean dismissedByUser) {
stopForeground(true);
isForegroundService = false;
stopSelf();
@@ -292,12 +287,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
this.context = context;
try {
this.controller = new MediaControllerCompat(context, token);
} catch (RemoteException e) {
throw new IllegalArgumentException("Failed to create controller from token", e);
}
this.context = context;
this.controller = new MediaControllerCompat(context, token);
}
void register() {
@@ -332,12 +323,8 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
private boolean registered;
private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) {
this.context = context;
try {
this.controller = new MediaControllerCompat(context, token);
} catch (RemoteException e) {
throw new IllegalArgumentException("Failed to create controller from token", e);
}
this.context = context;
this.controller = new MediaControllerCompat(context, token);
}
void register() {

View File

@@ -0,0 +1,38 @@
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultLoadControl
import com.google.android.exoplayer2.ForwardingPlayer
import com.google.android.exoplayer2.SimpleExoPlayer
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory
class VoiceNotePlayer @JvmOverloads constructor(
context: Context,
val internalPlayer: SimpleExoPlayer = SimpleExoPlayer.Builder(context)
.setMediaSourceFactory(AttachmentMediaSourceFactory(context))
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)
.build()
).build()
) : ForwardingPlayer(internalPlayer) {
override fun seekTo(windowIndex: Int, positionMs: Long) {
super.seekTo(windowIndex, positionMs)
val isQueueToneIndex = windowIndex % 2 == 1
val isSeekingToStart = positionMs == C.TIME_UNSET
return if (isQueueToneIndex && isSeekingToStart) {
val nextVoiceNoteWindowIndex = if (currentWindowIndex < windowIndex) windowIndex + 1 else windowIndex - 1
if (mediaItemCount <= nextVoiceNoteWindowIndex) {
super.seekTo(windowIndex, positionMs)
} else {
super.seekTo(nextVoiceNoteWindowIndex, positionMs)
}
} else {
super.seekTo(windowIndex, positionMs)
}
}
}

View File

@@ -1,93 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.net.Uri;
import android.support.v4.media.MediaDescriptionCompat;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor;
import org.signal.core.util.logging.Log;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
/**
* DataAdapter which maintains the current queue of MediaDescriptionCompat objects.
*/
@MainThread
final class VoiceNoteQueueDataAdapter implements TimelineQueueEditor.QueueDataAdapter {
private static final String TAG = Log.tag(VoiceNoteQueueDataAdapter.class);
public static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build();
private final List<MediaDescriptionCompat> descriptions = new LinkedList<>();
@Override
public MediaDescriptionCompat getMediaDescription(int position) {
if (descriptions.size() <= position) {
Log.i(TAG, "getMediaDescription: Returning EMPTY MediaDescriptionCompat for index " + position);
return EMPTY;
}
return descriptions.get(position);
}
@Override
public void add(int position, MediaDescriptionCompat description) {
descriptions.add(position, description);
}
@Override
public void remove(int position) {
descriptions.remove(position);
}
@Override
public void move(int from, int to) {
MediaDescriptionCompat description = descriptions.remove(from);
descriptions.add(to, description);
}
int size() {
return descriptions.size();
}
int indexOf(@NonNull Uri uri) {
for (int i = 0; i < descriptions.size(); i++) {
if (Objects.equals(uri, descriptions.get(i).getMediaUri())) {
return i;
}
}
return -1;
}
int indexAfter(@NonNull MediaDescriptionCompat target) {
if (isEmpty()) {
return 0;
}
long targetMessageId = target.getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID);
for (int i = 0; i < descriptions.size(); i++) {
long descriptionMessageId = descriptions.get(i).getExtras().getLong(VoiceNoteMediaDescriptionCompatFactory.EXTRA_MESSAGE_ID);
if (descriptionMessageId > targetMessageId) {
return i;
}
}
return descriptions.size();
}
boolean isEmpty() {
return descriptions.isEmpty();
}
void clear() {
descriptions.clear();
}
}

View File

@@ -5,24 +5,33 @@ import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueEditor;
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator;
/**
* Navigator to help support seek forward and back.
*/
final class VoiceNoteQueueNavigator extends TimelineQueueNavigator {
private static final MediaDescriptionCompat EMPTY = new MediaDescriptionCompat.Builder().build();
private final TimelineQueueEditor.QueueDataAdapter queueDataAdapter;
public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession, @NonNull TimelineQueueEditor.QueueDataAdapter queueDataAdapter) {
public VoiceNoteQueueNavigator(@NonNull MediaSessionCompat mediaSession) {
super(mediaSession);
this.queueDataAdapter = queueDataAdapter;
}
@Override
public MediaDescriptionCompat getMediaDescription(Player player, int windowIndex) {
return queueDataAdapter.getMediaDescription(windowIndex);
public @NonNull MediaDescriptionCompat getMediaDescription(@NonNull Player player, int windowIndex) {
MediaItem mediaItem = windowIndex >= 0 && windowIndex < player.getMediaItemCount() ? player.getMediaItemAt(windowIndex) : null;
if (mediaItem == null || mediaItem.playbackProperties == null) {
return EMPTY;
}
MediaDescriptionCompat mediaDescriptionCompat = (MediaDescriptionCompat) mediaItem.playbackProperties.tag;
if (mediaDescriptionCompat == null) {
return EMPTY;
}
return mediaDescriptionCompat;
}
}

View File

@@ -1,376 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import androidx.annotation.NonNull;
import com.annimon.stream.ComparatorCompat;
import com.annimon.stream.OptionalLong;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Represents the state of all participants, remote and local, combined with view state
* needed to properly render the participants. The view state primarily consists of
* if we are in System PIP mode and if we should show our video for an outgoing call.
*/
public final class CallParticipantsState {
private static final int SMALL_GROUP_MAX = 6;
public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED,
WebRtcViewModel.GroupCallState.IDLE,
new ParticipantCollection(SMALL_GROUP_MAX),
CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(), false),
CallParticipant.EMPTY,
WebRtcLocalRenderState.GONE,
false,
false,
false,
OptionalLong.empty(),
WebRtcControls.FoldableState.flat());
private final WebRtcViewModel.State callState;
private final WebRtcViewModel.GroupCallState groupCallState;
private final ParticipantCollection remoteParticipants;
private final CallParticipant localParticipant;
private final CallParticipant focusedParticipant;
private final WebRtcLocalRenderState localRenderState;
private final boolean isInPipMode;
private final boolean showVideoForOutgoing;
private final boolean isViewingFocusedParticipant;
private final OptionalLong remoteDevicesCount;
private final WebRtcControls.FoldableState foldableState;
public CallParticipantsState(@NonNull WebRtcViewModel.State callState,
@NonNull WebRtcViewModel.GroupCallState groupCallState,
@NonNull ParticipantCollection remoteParticipants,
@NonNull CallParticipant localParticipant,
@NonNull CallParticipant focusedParticipant,
@NonNull WebRtcLocalRenderState localRenderState,
boolean isInPipMode,
boolean showVideoForOutgoing,
boolean isViewingFocusedParticipant,
OptionalLong remoteDevicesCount,
@NonNull WebRtcControls.FoldableState foldableState)
{
this.callState = callState;
this.groupCallState = groupCallState;
this.remoteParticipants = remoteParticipants;
this.localParticipant = localParticipant;
this.localRenderState = localRenderState;
this.focusedParticipant = focusedParticipant;
this.isInPipMode = isInPipMode;
this.showVideoForOutgoing = showVideoForOutgoing;
this.isViewingFocusedParticipant = isViewingFocusedParticipant;
this.remoteDevicesCount = remoteDevicesCount;
this.foldableState = foldableState;
}
public @NonNull WebRtcViewModel.State getCallState() {
return callState;
}
public @NonNull WebRtcViewModel.GroupCallState getGroupCallState() {
return groupCallState;
}
public @NonNull List<CallParticipant> getGridParticipants() {
return remoteParticipants.getGridParticipants();
}
public @NonNull List<CallParticipant> getListParticipants() {
List<CallParticipant> listParticipants = new ArrayList<>();
if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) {
listParticipants.addAll(getAllRemoteParticipants());
listParticipants.remove(focusedParticipant);
} else {
listParticipants.addAll(remoteParticipants.getListParticipants());
}
if (foldableState.isFlat()) {
listParticipants.add(CallParticipant.EMPTY);
}
Collections.reverse(listParticipants);
return listParticipants;
}
public @NonNull String getRemoteParticipantsDescription(@NonNull Context context) {
switch (remoteParticipants.size()) {
case 0:
return context.getString(R.string.WebRtcCallView__no_one_else_is_here);
case 1: {
if (callState == WebRtcViewModel.State.CALL_PRE_JOIN && groupCallState.isNotIdle()) {
return context.getString(R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants.get(0).getShortRecipientDisplayName(context));
} else {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return remoteParticipants.get(0).getRecipientDisplayName(context);
}
}
}
case 2: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context));
}
}
default: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
int others = remoteParticipants.size() - 2;
return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context),
others);
}
}
}
}
public @NonNull List<CallParticipant> getAllRemoteParticipants() {
return remoteParticipants.getAllParticipants();
}
public @NonNull CallParticipant getLocalParticipant() {
return localParticipant;
}
public @NonNull CallParticipant getFocusedParticipant() {
return focusedParticipant;
}
public @NonNull WebRtcLocalRenderState getLocalRenderState() {
return localRenderState;
}
public boolean isFolded() {
return foldableState.isFolded();
}
public boolean isLargeVideoGroup() {
return getAllRemoteParticipants().size() > SMALL_GROUP_MAX;
}
public boolean isInPipMode() {
return isInPipMode;
}
public boolean isViewingFocusedParticipant() {
return isViewingFocusedParticipant;
}
public boolean needsNewRequestSizes() {
if (groupCallState.isNotIdle()) {
return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize());
} else {
return false;
}
}
public @NonNull OptionalLong getRemoteDevicesCount() {
return remoteDevicesCount;
}
public @NonNull OptionalLong getParticipantCount() {
boolean includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED;
return remoteDevicesCount.map(l -> l + (includeSelf ? 1L : 0L))
.or(() -> includeSelf ? OptionalLong.of(1L) : OptionalLong.empty());
}
public boolean isIncomingRing() {
return callState == WebRtcViewModel.State.CALL_INCOMING;
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState,
@NonNull WebRtcViewModel webRtcViewModel,
boolean enableVideo)
{
boolean newShowVideoForOutgoing = oldState.showVideoForOutgoing;
if (enableVideo) {
newShowVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING;
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) {
newShowVideoForOutgoing = false;
}
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(webRtcViewModel.getLocalParticipant(),
oldState.isInPipMode,
newShowVideoForOutgoing,
webRtcViewModel.getGroupState().isNotIdle(),
webRtcViewModel.getState(),
webRtcViewModel.getRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(webRtcViewModel.getState(),
webRtcViewModel.getGroupState(),
oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()),
webRtcViewModel.getLocalParticipant(),
getFocusedParticipant(webRtcViewModel.getRemoteParticipants()),
localRenderState,
oldState.isInPipMode,
newShowVideoForOutgoing,
oldState.isViewingFocusedParticipant,
webRtcViewModel.getRemoteDevicesCount(),
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
isInPip,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
isInPip,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
expanded);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
selectedPage == SelectedPage.FOCUSED,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
selectedPage == SelectedPage.FOCUSED,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull WebRtcControls.FoldableState foldableState) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
foldableState);
}
private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant,
boolean isInPip,
boolean showVideoForOutgoing,
boolean isNonIdleGroupCall,
@NonNull WebRtcViewModel.State callState,
int numberOfRemoteParticipants,
boolean isViewingFocusedParticipant,
boolean isExpanded)
{
boolean displayLocal = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled());
WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE;
if (isExpanded && (localParticipant.isVideoEnabled() || isNonIdleGroupCall)) {
return WebRtcLocalRenderState.EXPANDED;
} else if (displayLocal || showVideoForOutgoing) {
if (callState == WebRtcViewModel.State.CALL_CONNECTED) {
if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) {
localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE;
} else if (numberOfRemoteParticipants == 1) {
localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE;
} else {
localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
} else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) {
localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
} else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) {
localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
return localRenderState;
}
private static @NonNull CallParticipant getFocusedParticipant(@NonNull List<CallParticipant> participants) {
List<CallParticipant> participantsByLastSpoke = new ArrayList<>(participants);
Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke())));
return participantsByLastSpoke.isEmpty() ? CallParticipant.EMPTY
: participantsByLastSpoke.stream()
.filter(CallParticipant::isScreenSharing)
.findAny().orElse(participantsByLastSpoke.get(0));
}
public enum SelectedPage {
GRID,
FOCUSED
}
}

View File

@@ -0,0 +1,345 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import java.util.concurrent.TimeUnit
/**
* Represents the state of all participants, remote and local, combined with view state
* needed to properly render the participants. The view state primarily consists of
* if we are in System PIP mode and if we should show our video for an outgoing call.
*/
data class CallParticipantsState(
val callState: WebRtcViewModel.State = WebRtcViewModel.State.CALL_DISCONNECTED,
val groupCallState: WebRtcViewModel.GroupCallState = WebRtcViewModel.GroupCallState.IDLE,
private val remoteParticipants: ParticipantCollection = ParticipantCollection(SMALL_GROUP_MAX),
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false),
val focusedParticipant: CallParticipant = CallParticipant.EMPTY,
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false,
val ringGroup: Boolean = false,
val ringerRecipient: Recipient = Recipient.UNKNOWN,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList()
) {
val allRemoteParticipants: List<CallParticipant> = remoteParticipants.allParticipants
val isFolded: Boolean = foldableState.isFolded
val isLargeVideoGroup: Boolean = allRemoteParticipants.size > SMALL_GROUP_MAX
val isIncomingRing: Boolean = callState == WebRtcViewModel.State.CALL_INCOMING
val gridParticipants: List<CallParticipant>
get() {
return remoteParticipants.gridParticipants
}
val listParticipants: List<CallParticipant>
get() {
val listParticipants: MutableList<CallParticipant> = mutableListOf()
if (isViewingFocusedParticipant && allRemoteParticipants.size > 1) {
listParticipants.addAll(allRemoteParticipants)
listParticipants.remove(focusedParticipant)
} else {
listParticipants.addAll(remoteParticipants.listParticipants)
}
if (foldableState.isFlat) {
listParticipants.add(CallParticipant.EMPTY)
}
listParticipants.reverse()
return listParticipants
}
val participantCount: OptionalLong
get() {
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
}
fun getPreJoinGroupDescription(context: Context): String? {
if (callState != WebRtcViewModel.State.CALL_PRE_JOIN || groupCallState.isIdle) {
return null
}
return if (remoteParticipants.isEmpty) {
describeGroupMembers(
context = context,
oneParticipant = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s else R.string.WebRtcCallView__s_will_be_notified,
twoParticipants = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s_and_s else R.string.WebRtcCallView__s_and_s_will_be_notified,
multipleParticipants = if (ringGroup) R.plurals.WebRtcCallView__signal_will_ring_s_s_and_d_others else R.plurals.WebRtcCallView__s_s_and_d_others_will_be_notified,
members = groupMembers
)
} else {
when (remoteParticipants.size()) {
0 -> context.getString(R.string.WebRtcCallView__no_one_else_is_here)
1 -> context.getString(if (remoteParticipants[0].isSelf) R.string.WebRtcCallView__s_are_in_this_call else R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants[0].getShortRecipientDisplayName(context))
2 -> context.getString(
R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants[0].getShortRecipientDisplayName(context),
remoteParticipants[1].getShortRecipientDisplayName(context)
)
else -> {
val others = remoteParticipants.size() - 2
context.resources.getQuantityString(
R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants[0].getShortRecipientDisplayName(context),
remoteParticipants[1].getShortRecipientDisplayName(context),
others
)
}
}
}
}
fun getOutgoingRingingGroupDescription(context: Context): String? {
if (callState == WebRtcViewModel.State.CALL_CONNECTED &&
groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED &&
isInOutgoingRingingMode
) {
return describeGroupMembers(
context = context,
oneParticipant = R.string.WebRtcCallView__ringing_s,
twoParticipants = R.string.WebRtcCallView__ringing_s_and_s,
multipleParticipants = R.plurals.WebRtcCallView__ringing_s_s_and_d_others,
members = groupMembers
)
}
return null
}
fun getIncomingRingingGroupDescription(context: Context): String? {
if (callState == WebRtcViewModel.State.CALL_INCOMING &&
groupCallState == WebRtcViewModel.GroupCallState.RINGING &&
ringerRecipient.hasUuid()
) {
val ringerName = ringerRecipient.getShortDisplayName(context)
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireUuid() == it.member.uuid.orNull() }
return when (membersWithoutYouOrRinger.size) {
0 -> context.getString(R.string.WebRtcCallView__s_is_calling_you, ringerName)
1 -> context.getString(
R.string.WebRtcCallView__s_is_calling_you_and_s,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context)
)
2 -> context.getString(
R.string.WebRtcCallView__s_is_calling_you_s_and_s,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context),
membersWithoutYouOrRinger[1].member.getShortDisplayName(context)
)
else -> {
val others = membersWithoutYouOrRinger.size - 2
context.resources.getQuantityString(
R.plurals.WebRtcCallView__s_is_calling_you_s_s_and_d_others,
others,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context),
membersWithoutYouOrRinger[1].member.getShortDisplayName(context),
others
)
}
}
}
return null
}
fun needsNewRequestSizes(): Boolean {
return if (groupCallState.isNotIdle) {
allRemoteParticipants.any { it.videoSink.needsNewRequestingSize() }
} else {
false
}
}
companion object {
private const val SMALL_GROUP_MAX = 6
@JvmField
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)
@JvmField
val STARTING_STATE = CallParticipantsState()
@JvmStatic
fun update(
oldState: CallParticipantsState,
webRtcViewModel: WebRtcViewModel,
enableVideo: Boolean
): CallParticipantsState {
var newShowVideoForOutgoing: Boolean = oldState.showVideoForOutgoing
if (enableVideo) {
newShowVideoForOutgoing = webRtcViewModel.state == WebRtcViewModel.State.CALL_OUTGOING
} else if (webRtcViewModel.state != WebRtcViewModel.State.CALL_OUTGOING) {
newShowVideoForOutgoing = false
}
val isInOutgoingRingingMode = if (oldState.isInOutgoingRingingMode) {
webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() && webRtcViewModel.remoteParticipants.size == 0
} else {
oldState.ringGroup &&
webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() &&
webRtcViewModel.remoteParticipants.size == 0 &&
oldState.callState == WebRtcViewModel.State.CALL_OUTGOING &&
webRtcViewModel.state == WebRtcViewModel.State.CALL_CONNECTED
}
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(
oldState = oldState,
localParticipant = webRtcViewModel.localParticipant,
showVideoForOutgoing = newShowVideoForOutgoing,
isNonIdleGroupCall = webRtcViewModel.groupState.isNotIdle,
callState = webRtcViewModel.state,
numberOfRemoteParticipants = webRtcViewModel.remoteParticipants.size
)
return oldState.copy(
callState = webRtcViewModel.state,
groupCallState = webRtcViewModel.groupState,
remoteParticipants = oldState.remoteParticipants.getNext(webRtcViewModel.remoteParticipants),
localParticipant = webRtcViewModel.localParticipant,
focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants),
localRenderState = localRenderState,
showVideoForOutgoing = newShowVideoForOutgoing,
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.shouldRingGroup(),
isInOutgoingRingingMode = isInOutgoingRingingMode,
ringerRecipient = webRtcViewModel.ringerRecipient
)
}
@JvmStatic
fun update(oldState: CallParticipantsState, isInPip: Boolean): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isInPip = isInPip)
return oldState.copy(localRenderState = localRenderState, isInPipMode = isInPip)
}
@JvmStatic
fun setExpanded(oldState: CallParticipantsState, expanded: Boolean): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isExpanded = expanded)
return oldState.copy(localRenderState = localRenderState)
}
@JvmStatic
fun update(oldState: CallParticipantsState, selectedPage: SelectedPage): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED)
return oldState.copy(localRenderState = localRenderState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED)
}
@JvmStatic
fun update(oldState: CallParticipantsState, foldableState: FoldableState): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState)
return oldState.copy(localRenderState = localRenderState, foldableState = foldableState)
}
@JvmStatic
fun update(oldState: CallParticipantsState, groupMembers: List<GroupMemberEntry.FullMember>): CallParticipantsState {
return oldState.copy(groupMembers = groupMembers)
}
private fun determineLocalRenderMode(
oldState: CallParticipantsState,
localParticipant: CallParticipant = oldState.localParticipant,
isInPip: Boolean = oldState.isInPipMode,
showVideoForOutgoing: Boolean = oldState.showVideoForOutgoing,
isNonIdleGroupCall: Boolean = oldState.groupCallState.isNotIdle,
callState: WebRtcViewModel.State = oldState.callState,
numberOfRemoteParticipants: Int = oldState.allRemoteParticipants.size,
isViewingFocusedParticipant: Boolean = oldState.isViewingFocusedParticipant,
isExpanded: Boolean = oldState.localRenderState == WebRtcLocalRenderState.EXPANDED
): WebRtcLocalRenderState {
val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled)
var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE
if (isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) {
return WebRtcLocalRenderState.EXPANDED
} else if (displayLocal || showVideoForOutgoing) {
if (callState == WebRtcViewModel.State.CALL_CONNECTED) {
localRenderState = if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) {
WebRtcLocalRenderState.SMALLER_RECTANGLE
} else if (numberOfRemoteParticipants == 1) {
WebRtcLocalRenderState.SMALL_RECTANGLE
} else {
if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO
}
} else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) {
localRenderState = if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO
}
} else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) {
localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO
}
return localRenderState
}
private fun getFocusedParticipant(participants: List<CallParticipant>): CallParticipant {
val participantsByLastSpoke: List<CallParticipant> = participants.sortedByDescending(CallParticipant::lastSpoke)
return if (participantsByLastSpoke.isEmpty()) {
CallParticipant.EMPTY
} else {
participantsByLastSpoke.firstOrNull(CallParticipant::isScreenSharing) ?: participantsByLastSpoke[0]
}
}
private fun describeGroupMembers(
context: Context,
@StringRes oneParticipant: Int,
@StringRes twoParticipants: Int,
@PluralsRes multipleParticipants: Int,
members: List<GroupMemberEntry.FullMember>
): String {
val membersWithoutYou: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf }
return when (membersWithoutYou.size) {
0 -> ""
1 -> context.getString(
oneParticipant,
membersWithoutYou[0].member.getShortDisplayName(context)
)
2 -> context.getString(
twoParticipants,
membersWithoutYou[0].member.getShortDisplayName(context),
membersWithoutYou[1].member.getShortDisplayName(context)
)
else -> {
val others = membersWithoutYou.size - 2
context.resources.getQuantityString(
multipleParticipants,
others,
membersWithoutYou[0].member.getShortDisplayName(context),
membersWithoutYou[1].member.getShortDisplayName(context),
others
)
}
}
}
}
enum class SelectedPage {
GRID, FOCUSED
}
}

View File

@@ -42,8 +42,7 @@ class WebRtcCallRepository {
@WorkerThread
void getIdentityRecords(@NonNull Recipient recipient, @NonNull Consumer<IdentityRecordList> consumer) {
SignalExecutors.BOUNDED.execute(() -> {
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
List<Recipient> recipients;
List<Recipient> recipients;
if (recipient.isGroup()) {
recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
@@ -51,7 +50,7 @@ class WebRtcCallRepository {
recipients = Collections.singletonList(recipient);
}
consumer.accept(identityDatabase.getIdentities(recipients));
consumer.accept(ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients));
});
}
}

View File

@@ -91,6 +91,8 @@ public class WebRtcCallView extends ConstraintLayout {
private ImageView answer;
private ImageView cameraDirectionToggle;
private TextView cameraDirectionToggleLabel;
private AccessibleToggleButton ringToggle;
private TextView ringToggleLabel;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView hangup;
private TextView hangupLabel;
@@ -171,6 +173,8 @@ public class WebRtcCallView extends ConstraintLayout {
answer = findViewById(R.id.call_screen_answer_call);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
cameraDirectionToggleLabel = findViewById(R.id.call_screen_camera_direction_toggle_label);
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label);
hangup = findViewById(R.id.call_screen_end_call);
hangupLabel = findViewById(R.id.call_screen_end_call_label);
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
@@ -239,6 +243,10 @@ public class WebRtcCallView extends ConstraintLayout {
runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn));
});
ringToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated()));
});
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed));
@@ -283,6 +291,7 @@ public class WebRtcCallView extends ConstraintLayout {
rotatableControls.add(cameraDirectionToggle);
rotatableControls.add(decline);
rotatableControls.add(smallLocalRender.findViewById(R.id.call_participant_mic_muted));
rotatableControls.add(ringToggle);
smallHeaderConstraints = new ConstraintSet();
smallHeaderConstraints.clone(getContext(), R.layout.webrtc_call_view_header_small);
@@ -358,8 +367,14 @@ public class WebRtcCallView extends ConstraintLayout {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled));
}
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN && state.getGroupCallState().isNotIdle()) {
status.setText(state.getRemoteParticipantsDescription(getContext()));
if (state.getGroupCallState().isNotIdle()) {
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
status.setText(state.getPreJoinGroupDescription(getContext()));
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
status.setText(state.getOutgoingRingingGroupDescription(getContext()));
} else if (state.getGroupCallState().isRinging()) {
status.setText(state.getIncomingRingingGroupDescription(getContext()));
}
}
if (state.getGroupCallState().isNotIdle()) {
@@ -641,6 +656,11 @@ public class WebRtcCallView extends ConstraintLayout {
fullScreenShade.setVisibility(GONE);
}
if (webRtcControls.displayRingToggle()) {
visibleViewSet.add(ringToggle);
visibleViewSet.add(ringToggleLabel);
}
if (webRtcControls.isFadeOutEnabled()) {
if (!controls.isFadeOutEnabled()) {
scheduleFadeOut();
@@ -947,6 +967,7 @@ public class WebRtcCallView extends ConstraintLayout {
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle);
}
private void updateButtonStateForSmallButtons() {
@@ -955,6 +976,7 @@ public class WebRtcCallView extends ConstraintLayout {
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle_small);
}
private boolean showParticipantsList() {
@@ -968,6 +990,14 @@ public class WebRtcCallView extends ConstraintLayout {
}
}
public void setRingGroup(boolean shouldRingGroup) {
ringToggle.setChecked(shouldRingGroup, false);
}
public void enableRingGroup(boolean enabled) {
ringToggle.setActivated(enabled);
}
public interface ControlsListener {
void onStartCall(boolean isVideoCall);
void onCancelStartCall();
@@ -985,5 +1015,6 @@ public class WebRtcCallView extends ConstraintLayout {
void onShowParticipantsList();
void onPageChanged(@NonNull CallParticipantsState.SelectedPage page);
void onLocalPictureInPictureClicked();
void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed);
}
}

View File

@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -19,6 +20,7 @@ import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
@@ -29,6 +31,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
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.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
@@ -40,32 +43,38 @@ import java.util.Objects;
public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final MutableLiveData<CallParticipantsState> participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private final MutableLiveData<Collection<RecipientId>> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
private final LiveData<SafetyNumberChangeEvent> safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
private final LiveData<Recipient> groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = LiveDataUtil.skip(Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())), 1);
private final LiveData<Boolean> shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final DefaultValueLiveData<CallParticipantsState> participantsState = new DefaultValueLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private final MutableLiveData<Collection<RecipientId>> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
private final LiveData<SafetyNumberChangeEvent> safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
private final LiveData<Recipient> groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers()));
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
private final LiveData<Integer> groupMemberCount = Transformations.map(groupMembers, List::size);
private final LiveData<Boolean> shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m));
private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
private final Runnable elapsedTimeRunnable = this::handleTick;
private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode;
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
private boolean wasInOutgoingRingingMode = false;
private long callConnectedTime = -1;
private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
private boolean answerWithVideoAvailable = false;
private Runnable elapsedTimeRunnable = this::handleTick;
private boolean canEnterPipMode = false;
private List<CallParticipant> previousParticipantsList = Collections.emptyList();
private boolean callStarting = false;
@@ -79,6 +88,8 @@ public class WebRtcCallViewModel extends ViewModel {
controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled),
Transformations.distinctUntilChanged(orientation),
this::resolveRotation);
groupMembers.observeForever(groupMemberStateUpdater);
}
public LiveData<Integer> getControlsRotation() {
@@ -135,8 +146,12 @@ public class WebRtcCallViewModel extends ViewModel {
return safetyNumberChangeEvent;
}
public LiveData<List<GroupMemberEntry.FullMember>> getGroupMembers() {
return groupMembers;
public LiveData<List<GroupMemberEntry.FullMember>> getGroupMembersChanged() {
return groupMembersChanged;
}
public LiveData<Integer> getGroupMemberCount() {
return groupMemberCount;
}
public LiveData<Boolean> shouldShowSpeakerHint() {
@@ -159,7 +174,6 @@ public class WebRtcCallViewModel extends ViewModel {
public void setIsInPipMode(boolean isInPipMode) {
this.isInPipMode.setValue(isInPipMode);
//noinspection ConstantConditions
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode));
}
@@ -174,11 +188,11 @@ public class WebRtcCallViewModel extends ViewModel {
}
CallParticipantsState state = participantsState.getValue();
if (state != null &&
showScreenShareTip &&
if (showScreenShareTip &&
state.getFocusedParticipant().isScreenSharing() &&
state.isViewingFocusedParticipant() &&
page == CallParticipantsState.SelectedPage.GRID) {
page == CallParticipantsState.SelectedPage.GRID)
{
showScreenShareTip = false;
events.setValue(new Event.ShowSwipeToSpeakerHint());
}
@@ -211,15 +225,14 @@ public class WebRtcCallViewModel extends ViewModel {
microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled());
CallParticipantsState state = participantsState.getValue();
if (state != null) {
boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
participantsState.setValue(newState);
if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
switchOnFirstScreenShare = false;
events.setValue(new Event.SwitchToSpeaker());
}
CallParticipantsState state = participantsState.getValue();
boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
participantsState.setValue(newState);
if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
switchOnFirstScreenShare = false;
events.setValue(new Event.SwitchToSpeaker());
}
if (webRtcViewModel.getGroupState().isConnected()) {
@@ -245,12 +258,20 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcViewModel.getRemoteDevicesCount().orElse(0),
webRtcViewModel.getParticipantLimit());
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = webRtcViewModel.getCallConnectedTime();
startTimer();
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) {
if (newState.isInOutgoingRingingMode()) {
cancelTimer();
callConnectedTime = -1;
if (!wasInOutgoingRingingMode) {
elapsedTimeHandler.postDelayed(stopOutgoingRingingMode, CallParticipantsState.MAX_OUTGOING_GROUP_RING_DURATION);
}
wasInOutgoingRingingMode = true;
} else {
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = wasInOutgoingRingingMode ? System.currentTimeMillis() : webRtcViewModel.getCallConnectedTime();
startTimer();
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) {
cancelTimer();
callConnectedTime = -1;
}
}
if (localParticipant.getCameraState().isEnabled()) {
@@ -371,18 +392,26 @@ public class WebRtcCallViewModel extends ViewModel {
}
private boolean shouldShowSpeakerHint(@NonNull CallParticipantsState state) {
return !state.isInPipMode() &&
return !state.isInPipMode() &&
state.getRemoteDevicesCount().orElse(0) > 1 &&
state.getGroupCallState().isConnected() &&
state.getGroupCallState().isConnected() &&
!SignalStore.tooltips().hasSeenGroupCallSpeakerView();
}
private void startTimer() {
cancelTimer();
elapsedTimeHandler.removeCallbacks(stopOutgoingRingingMode);
elapsedTimeHandler.post(elapsedTimeRunnable);
}
private void stopOutgoingRingingMode() {
if (callConnectedTime == -1) {
callConnectedTime = System.currentTimeMillis();
startTimer();
}
}
private void handleTick() {
if (callConnectedTime == -1) {
return;
@@ -403,6 +432,7 @@ public class WebRtcCallViewModel extends ViewModel {
protected void onCleared() {
super.onCleared();
cancelTimer();
groupMembers.removeObserver(groupMemberStateUpdater);
}
public void startCall(boolean isVideoCall) {
@@ -411,7 +441,7 @@ public class WebRtcCallViewModel extends ViewModel {
if (recipient.isGroup()) {
repository.getIdentityRecords(recipient, identityRecords -> {
if (identityRecords.isUntrusted(false) || identityRecords.isUnverified(false)) {
List<IdentityDatabase.IdentityRecord> records = identityRecords.getUnverifiedRecords();
List<IdentityRecord> records = identityRecords.getUnverifiedRecords();
records.addAll(identityRecords.getUntrustedRecords());
events.postValue(new Event.ShowGroupCallSafetyNumberChange(records));
} else {
@@ -446,13 +476,13 @@ public class WebRtcCallViewModel extends ViewModel {
}
public static class ShowGroupCallSafetyNumberChange extends Event {
private final List<IdentityDatabase.IdentityRecord> identityRecords;
private final List<IdentityRecord> identityRecords;
public ShowGroupCallSafetyNumberChange(@NonNull List<IdentityDatabase.IdentityRecord> identityRecords) {
public ShowGroupCallSafetyNumberChange(@NonNull List<IdentityRecord> identityRecords) {
this.identityRecords = identityRecords;
}
public @NonNull List<IdentityDatabase.IdentityRecord> getIdentityRecords() {
public @NonNull List<IdentityRecord> getIdentityRecords() {
return identityRecords;
}
}

View File

@@ -8,6 +8,7 @@ import androidx.annotation.Px;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.FeatureFlags;
public final class WebRtcControls {
@@ -183,6 +184,10 @@ public final class WebRtcControls {
return isPreJoin() || isIncoming();
}
boolean displayRingToggle() {
return FeatureFlags.groupCallRinging() && isPreJoin() && isGroupCall() && !hasAtLeastOneRemote;
}
private boolean isError() {
return callState == CallState.ERROR;
}

View File

@@ -26,12 +26,9 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.UsernameUtil;
@@ -48,15 +45,16 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
private static final String TAG = Log.tag(ContactsCursorLoader.class);
public static final class DisplayMode {
public static final int FLAG_PUSH = 1;
public static final int FLAG_SMS = 1 << 1;
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
public static final int FLAG_SELF = 1 << 4;
public static final int FLAG_BLOCK = 1 << 5;
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
public static final int FLAG_PUSH = 1;
public static final int FLAG_SMS = 1 << 1;
public static final int FLAG_ACTIVE_GROUPS = 1 << 2;
public static final int FLAG_INACTIVE_GROUPS = 1 << 3;
public static final int FLAG_SELF = 1 << 4;
public static final int FLAG_BLOCK = 1 << 5;
public static final int FLAG_HIDE_GROUPS_V1 = 1 << 5;
public static final int FLAG_HIDE_NEW = 1 << 6;
public static final int FLAG_HIDE_RECENT_HEADER = 1 << 7;
public static final int FLAG_ALL = FLAG_PUSH | FLAG_SMS | FLAG_ACTIVE_GROUPS | FLAG_INACTIVE_GROUPS | FLAG_SELF;
}
private static final int RECENT_CONVERSATION_MAX = 25;
@@ -115,7 +113,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
Cursor recentConversations = getRecentConversationsCursor();
if (recentConversations.getCount() > 0) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
if (!hideRecentsHeader(mode)) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
}
cursorList.add(recentConversations);
}
}
@@ -139,7 +139,9 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
Cursor groups = getRecentConversationsCursor(true);
if (groups.getCount() > 0) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
if (!hideRecentsHeader(mode)) {
cursorList.add(ContactsCursorRows.forRecentsHeader(getContext()));
}
cursorList.add(groups);
}
}
@@ -279,6 +281,10 @@ public class ContactsCursorLoader extends AbstractContactsCursorLoader {
return flagSet(mode, DisplayMode.FLAG_HIDE_NEW);
}
private static boolean hideRecentsHeader(int mode) {
return flagSet(mode, DisplayMode.FLAG_HIDE_RECENT_HEADER);
}
private static boolean flagSet(int mode, int flag) {
return (mode & flag) > 0;
}

View File

@@ -175,7 +175,7 @@ public class DirectoryHelper {
recipient = Recipient.resolved(recipientDatabase.getByUuid(uuid).get());
}
} else {
recipientDatabase.markRegistered(recipient.getId());
Log.w(TAG, "Registered number set had a null UUID!");
}
} else if (recipient.hasUuid() && recipient.isRegistered() && hasCommunicatedWith(context, recipient)) {
if (isUuidRegistered(context, recipient)) {
@@ -469,8 +469,8 @@ public class DirectoryHelper {
for (RecipientId newUser: newUsers) {
Recipient recipient = Recipient.resolved(newUser);
if (!SessionUtil.hasSession(context, recipient.getId()) &&
!recipient.isSelf() &&
if (!SessionUtil.hasSession(recipient.getId()) &&
!recipient.isSelf() &&
recipient.hasAUserSetDisplayName(context))
{
IncomingJoinedMessage message = new IncomingJoinedMessage(recipient.getId());
@@ -543,8 +543,9 @@ public class DirectoryHelper {
}
private static boolean hasCommunicatedWith(@NonNull Context context, @NonNull Recipient recipient) {
return DatabaseFactory.getThreadDatabase(context).hasThread(recipient.getId()) ||
DatabaseFactory.getSessionDatabase(context).hasSessionFor(recipient.getId());
return DatabaseFactory.getThreadDatabase(context).hasThread(recipient.getId()) ||
(recipient.hasUuid() && DatabaseFactory.getSessionDatabase(context).hasSessionFor(recipient.requireUuid().toString())) ||
(recipient.hasE164() && DatabaseFactory.getSessionDatabase(context).hasSessionFor(recipient.requireE164()));
}
static class DirectoryResult {

View File

@@ -159,7 +159,7 @@ import org.thoughtcrime.securesms.database.DraftDatabase.Draft;
import org.thoughtcrime.securesms.database.DraftDatabase.Drafts;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions;
@@ -205,8 +205,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel;
import org.thoughtcrime.securesms.maps.PlacePickerActivity;
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity;
import org.thoughtcrime.securesms.messagerequests.MessageRequestState;
import org.thoughtcrime.securesms.messagerequests.MessageRequestViewModel;
@@ -276,6 +276,7 @@ import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SmsUtil;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -740,7 +741,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
initializeSecurity(isSecureText, isDefaultSms);
break;
case MEDIA_SENDER:
MediaSendActivityResult result = data.getParcelableExtra(MediaSendActivity.EXTRA_RESULT);
MediaSendActivityResult result = MediaSendActivityResult.fromData(data);
if (!Objects.equals(result.getRecipientId(), recipient.getId())) {
Log.w(TAG, "Result's recipientId did not match ours! Result: " + result.getRecipientId() + ", Activity: " + recipient.getId());
@@ -788,7 +789,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
result.isViewOnce(),
subscriptionId,
initiating,
true).addListener(new AssertedSuccessListener<Void>() {
true,
null).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -1142,7 +1144,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
@Override
public void onAttachmentMediaClicked(@NonNull Media media) {
linkPreviewViewModel.onUserCancel();
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
startActivityForResult(MediaSelectionActivity.editor(ConversationActivity.this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed()), MEDIA_SENDER);
container.hideCurrentInput(composeText);
}
@@ -1150,7 +1152,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
public void onAttachmentSelectorClicked(@NonNull AttachmentKeyboardButton button) {
switch (button) {
case GALLERY:
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
AttachmentManager.selectGallery(this, MEDIA_SENDER, recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport(), inputPanel.getQuote().isPresent());
break;
case FILE:
AttachmentManager.selectDocument(this, PICK_DOCUMENT);
@@ -1280,7 +1282,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
new AsyncTask<OutgoingEndSessionMessage, Void, Long>() {
@Override
protected Long doInBackground(OutgoingEndSessionMessage... messages) {
return MessageSender.send(context, messages[0], threadId, false, null);
return MessageSender.send(context, messages[0], threadId, false, null, null);
}
@Override
@@ -1428,7 +1430,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private void handleVideo(final Recipient recipient) {
if (recipient == null) return;
if (recipient.isPushV2Group() && groupViewModel.isNonAdminInAnnouncementGroup()) {
if (recipient.isPushV2Group() && groupCallViewModel.hasActiveGroupCall().getValue() == Boolean.FALSE && groupViewModel.isNonAdminInAnnouncementGroup()) {
new MaterialAlertDialogBuilder(this).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call)
.setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss())
@@ -1526,7 +1528,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
initializeIdentityRecords().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
sendMessage();
sendMessage(null);
}
});
}
@@ -1605,7 +1607,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
if (!Util.isEmpty(mediaList)) {
Log.d(TAG, "Handling shared Media.");
Intent sendIntent = MediaSendActivity.buildEditorIntent(this, mediaList, recipient.get(), draftText, sendButton.getSelectedTransport());
Intent sendIntent = MediaSelectionActivity.editor(this, sendButton.getSelectedTransport(), mediaList, recipient.getId(), draftText);
startActivityForResult(sendIntent, MEDIA_SENDER);
return new SettableFuture<>(false);
}
@@ -1671,7 +1673,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
throw new AssertionError();
}
if (selfMembership.isAnnouncementGroup() && selfMembership.getMemberLevel() != GroupDatabase.MemberLevel.ADMINISTRATOR) {
if (!leftGroup && !canCancelRequest && selfMembership.isAnnouncementGroup() && selfMembership.getMemberLevel() != GroupDatabase.MemberLevel.ADMINISTRATOR) {
canSendMessages = false;
cannotSendInAnnouncementGroupBanner.get().setVisibility(View.VISIBLE);
cannotSendInAnnouncementGroupBanner.get().setMovementMethod(LinkMovementMethod.getInstance());
@@ -1955,8 +1957,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
new AsyncTask<Recipient, Void, Pair<IdentityRecordList, String>>() {
@Override
protected @NonNull Pair<IdentityRecordList, String> doInBackground(Recipient... params) {
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this);
List<Recipient> recipients;
List<Recipient> recipients;
if (params[0].isGroup()) {
recipients = DatabaseFactory.getGroupDatabase(ConversationActivity.this)
@@ -1966,7 +1967,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
long startTime = System.currentTimeMillis();
IdentityRecordList identityRecordList = identityDatabase.getIdentities(recipients);
IdentityRecordList identityRecordList = ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients);
Log.i(TAG, String.format(Locale.US, "Loaded %d identities in %d ms", recipients.size(), System.currentTimeMillis() - startTime));
@@ -2562,7 +2563,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
Media media = new Media(uri, mimeType, 0, width, height, 0, 0, borderless, videoGif, Optional.absent(), Optional.absent(), Optional.absent());
startActivityForResult(MediaSendActivity.buildEditorIntent(ConversationActivity.this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport()), MEDIA_SENDER);
startActivityForResult(MediaSelectionActivity.editor(ConversationActivity.this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed()), MEDIA_SENDER);
return new SettableFuture<>(false);
} else {
return attachmentManager.setMedia(glideRequests, uri, mediaType, getCurrentMediaConstraints(), width, height);
@@ -2587,7 +2588,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
boolean initiating = threadId == -1;
sendMediaMessage(recipient.getId(), isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false);
sendMediaMessage(recipient.getId(), isSmsForced(), "", attachmentManager.buildSlideDeck(), null, contacts, Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, false, null);
}
private void selectContactInfo(ContactData contactData) {
@@ -2848,7 +2849,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
updateLinkPreviewState();
}
private void sendMessage() {
private void sendMessage(@Nullable String metricId) {
if (inputPanel.isRecordingInLockedMode()) {
inputPanel.releaseRecordingLock();
return;
@@ -2892,9 +2893,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
} else if (!forceSms && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(forceSms, expiresIn, false, subscriptionId, initiating);
sendMediaMessage(forceSms, expiresIn, false, subscriptionId, initiating, metricId);
} else {
sendTextMessage(forceSms, expiresIn, subscriptionId, initiating);
sendTextMessage(forceSms, expiresIn, subscriptionId, initiating, metricId);
}
} catch (RecipientFormattingException ex) {
Toast.makeText(ConversationActivity.this,
@@ -2925,7 +2926,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
long id = fragment.stageOutgoingMessage(secureMessage);
SimpleTask.run(() -> {
long resultId = MessageSender.sendPushWithPreUploadedMedia(this, secureMessage, result.getPreUploadResults(), thread, () -> fragment.releaseOutgoingMessage(id));
long resultId = MessageSender.sendPushWithPreUploadedMedia(this, secureMessage, result.getPreUploadResults(), thread, null);
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
@@ -2934,7 +2935,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
}, this::sendComplete);
}
private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, final boolean initiating)
private void sendMediaMessage(final boolean forceSms, final long expiresIn, final boolean viewOnce, final int subscriptionId, final boolean initiating, @Nullable String metricId)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -2951,7 +2952,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
viewOnce,
subscriptionId,
initiating,
true);
true,
metricId);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2966,7 +2968,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
final boolean viewOnce,
final int subscriptionId,
final boolean initiating,
final boolean clearComposeBox)
final boolean clearComposeBox,
final @Nullable String metricId)
{
if (!isDefaultSms && (!isSecureText || forceSms) && recipient.get().hasSmsAddress()) {
showDefaultSmsPrompt();
@@ -3013,7 +3016,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
final long id = fragment.stageOutgoingMessage(outgoingMessage);
SimpleTask.run(() -> {
return MessageSender.send(context, outgoingMessage, thread, forceSms, () -> fragment.releaseOutgoingMessage(id));
return MessageSender.send(context, outgoingMessage, thread, forceSms, metricId, null);
}, result -> {
sendComplete(result);
future.set(null);
@@ -3025,7 +3028,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
return future;
}
private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiating)
private void sendTextMessage(final boolean forceSms, final long expiresIn, final int subscriptionId, final boolean initiating, final @Nullable String metricId)
throws InvalidMessageException
{
if (!isDefaultSms && (!isSecureText || forceSms) && recipient.get().hasSmsAddress()) {
@@ -3054,7 +3057,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
.onAllGranted(() -> {
final long id = new SecureRandom().nextLong();
SimpleTask.run(() -> {
return MessageSender.send(context, message, thread, forceSms, () -> fragment.releaseOutgoingMessage(id));
return MessageSender.send(context, message, thread, forceSms, metricId, null);
}, this::sendComplete);
silentlySetComposeText("");
@@ -3283,7 +3286,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
false,
subscriptionId,
initiating,
true);
true,
null);
sendResult.addListener(new AssertedSuccessListener<Void>() {
@Override
@@ -3305,7 +3309,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private void sendSticker(@NonNull StickerLocator stickerLocator, @NonNull String contentType, @NonNull Uri uri, long size, boolean clearCompose) {
if (sendButton.getSelectedTransport().isSms()) {
Media media = new Media(uri, contentType, System.currentTimeMillis(), StickerSlide.WIDTH, StickerSlide.HEIGHT, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent());
Intent intent = MediaSendActivity.buildEditorIntent(this, Collections.singletonList(media), recipient.get(), composeText.getTextTrimmed(), sendButton.getSelectedTransport());
Intent intent = MediaSelectionActivity.editor(this, sendButton.getSelectedTransport(), Collections.singletonList(media), recipient.getId(), composeText.getTextTrimmed());
startActivityForResult(intent, MEDIA_SENDER);
return;
}
@@ -3319,7 +3323,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
slideDeck.addSlide(stickerSlide);
sendMediaMessage(recipient.getId(), transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose);
sendMediaMessage(recipient.getId(), transport.isSms(), "", slideDeck, null, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), expiresIn, false, subscriptionId, initiating, clearCompose, null);
}
private void silentlySetComposeText(String text) {
@@ -3444,7 +3448,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> {
composeText.clearFocus();
startActivityForResult(MediaSendActivity.buildCameraIntent(ConversationActivity.this, recipient.get(), sendButton.getSelectedTransport()), MEDIA_SENDER);
startActivityForResult(MediaSelectionActivity.camera(ConversationActivity.this, sendButton.getSelectedTransport(), recipient.getId(), inputPanel.getQuote().isPresent()), MEDIA_SENDER);
overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary);
})
.onAnyDenied(() -> Toast.makeText(ConversationActivity.this, R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
@@ -3455,7 +3459,9 @@ public class ConversationActivity extends PassphraseRequiredActivity
private class SendButtonListener implements OnClickListener, TextView.OnEditorActionListener {
@Override
public void onClick(View v) {
sendMessage();
String metricId = recipient.get().isGroup() ? SignalLocalMetrics.GroupMessageSend.start()
: SignalLocalMetrics.IndividualMessageSend.start();
sendMessage(metricId);
}
@Override
@@ -3670,7 +3676,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
{
reactionDelegate.setOnToolbarItemClickedListener(toolbarListener);
reactionDelegate.setOnHideListener(onHideListener);
reactionDelegate.show(this, maskTarget, recipient.get(), conversationMessage, inputAreaHeight());
reactionDelegate.show(this, maskTarget, recipient.get(), conversationMessage, inputAreaHeight(), groupViewModel.isNonAdminInAnnouncementGroup());
}
@Override
@@ -3940,33 +3946,23 @@ public class ConversationActivity extends PassphraseRequiredActivity
false,
subscriptionId,
initiating,
false);
false,
null);
}
private class UnverifiedDismissedListener implements UnverifiedBannerView.DismissListener {
@Override
public void onDismissed(final List<IdentityRecord> unverifiedIdentities) {
final IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(ConversationActivity.this);
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : unverifiedIdentities) {
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
VerifiedStatus.DEFAULT);
}
SimpleTask.run(() -> {
try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (IdentityRecord identityRecord : unverifiedIdentities) {
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
VerifiedStatus.DEFAULT);
}
return null;
}
@Override
protected void onPostExecute(Void result) {
initializeIdentityRecords();
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
return null;
}, nothing -> initializeIdentityRecords());
}
}

View File

@@ -37,7 +37,7 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.MediaItem;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
@@ -59,16 +59,13 @@ import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
@@ -120,7 +117,6 @@ public class ConversationAdapter
private final Set<Long> releasedFastRecords;
private final Calendar calendar;
private final MessageDigest digest;
private final AttachmentMediaSourceFactory attachmentMediaSourceFactory;
private String searchQuery;
private ConversationMessage recordToPulse;
@@ -138,7 +134,6 @@ public class ConversationAdapter
@NonNull Locale locale,
@Nullable ItemClickListener clickListener,
@NonNull Recipient recipient,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
@NonNull Colorizer colorizer)
{
super(new DiffUtil.ItemCallback<ConversationMessage>() {
@@ -167,7 +162,6 @@ public class ConversationAdapter
this.digest = getMessageDigestOrThrow();
this.hasWallpaper = recipient.hasWallpaper();
this.isMessageRequestAccepted = true;
this.attachmentMediaSourceFactory = attachmentMediaSourceFactory;
this.colorizer = colorizer;
setHasStableIds(true);
@@ -302,7 +296,6 @@ public class ConversationAdapter
conversationMessage == recordToPulse,
hasWallpaper,
isMessageRequestAccepted,
attachmentMediaSourceFactory,
conversationMessage == inlineContent,
colorizer);
@@ -346,8 +339,8 @@ public class ConversationAdapter
if (conversationMessage == null) return -1;
calendar.setTime(new Date(conversationMessage.getMessageRecord().getDateSent()));
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateReceived());
return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR);
}
@Override
@@ -567,6 +560,10 @@ public class ConversationAdapter
return new HashSet<>(selected);
}
public void removeFromSelection(@NonNull Set<MultiselectPart> parts) {
selected.removeAll(parts);
}
/**
* Clears all selected records from multi-select mode.
*/
@@ -698,8 +695,8 @@ public class ConversationAdapter
}
@Override
public @Nullable MediaSource getMediaSource() {
return getBindable().getMediaSource();
public @Nullable MediaItem getMediaItem() {
return getBindable().getMediaItem();
}
@Override
@@ -707,8 +704,8 @@ public class ConversationAdapter
return getBindable().getPlaybackPolicyEnforcer();
}
@NonNull
public @Override Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
@Override
public @NonNull Projection getGiphyMp4PlayableProjection(@NonNull ViewGroup recyclerView) {
return getBindable().getGiphyMp4PlayableProjection(recyclerView);
}

View File

@@ -13,16 +13,21 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.conversation.ConversationData.MessageRequestData;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MentionDatabase;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.Stopwatch;
import org.thoughtcrime.securesms.util.Util;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
@@ -32,7 +37,7 @@ import java.util.stream.Collectors;
/**
* Core data source for loading an individual conversation.
*/
class ConversationDataSource implements PagedDataSource<ConversationMessage> {
class ConversationDataSource implements PagedDataSource<MessageId, ConversationMessage> {
private static final String TAG = Log.tag(ConversationDataSource.class);
@@ -109,6 +114,48 @@ class ConversationDataSource implements PagedDataSource<ConversationMessage> {
return messages;
}
@Override
public @Nullable ConversationMessage load(@NonNull MessageId messageId) {
Stopwatch stopwatch = new Stopwatch("load(" + messageId + "), thread " + threadId);
MessageDatabase database = messageId.isMms() ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
MessageRecord record = database.getMessageRecordOrNull(messageId.getId());
stopwatch.split("message");
try {
if (record != null) {
List<Mention> mentions;
if (messageId.isMms()) {
mentions = DatabaseFactory.getMentionDatabase(context).getMentionsForMessage(messageId.getId());
} else {
mentions = Collections.emptyList();
}
stopwatch.split("mentions");
if (messageId.isMms()) {
List<DatabaseAttachment> attachments = DatabaseFactory.getAttachmentDatabase(context).getAttachmentsForMessage(messageId.getId());
if (attachments.size() > 0) {
record = ((MediaMmsMessageRecord) record).withAttachments(context, attachments);
}
}
stopwatch.split("attachments");
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(ApplicationDependencies.getApplication(), record, mentions);
} else {
return null;
}
} finally {
stopwatch.stop(TAG);
}
}
@Override
public @NonNull MessageId getKey(@NonNull ConversationMessage conversationMessage) {
return new MessageId(conversationMessage.getMessageRecord().getId(), conversationMessage.getMessageRecord().isMms());
}
private static class MentionHelper {
private Collection<Long> messageIds = new LinkedList<>();

View File

@@ -89,6 +89,7 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter.StickyHeaderV
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemAnimator;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
@@ -162,20 +163,20 @@ import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.util.views.AdaptiveActionsToolbar;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
@SuppressLint("StaticFieldLeak")
public class ConversationFragment extends LoggingFragment {
public class ConversationFragment extends LoggingFragment implements MultiselectForwardFragment.Callback {
private static final String TAG = Log.tag(ConversationFragment.class);
private static final int SCROLL_ANIMATION_THRESHOLD = 50;
@@ -206,6 +207,7 @@ public class ConversationFragment extends LoggingFragment {
private MessageRequestViewModel messageRequestViewModel;
private MessageCountsViewModel messageCountsViewModel;
private ConversationViewModel conversationViewModel;
private ConversationGroupViewModel groupViewModel;
private SnapToTopDataObserver snapToTopDataObserver;
private MarkReadHelper markReadHelper;
private Animation scrollButtonInAnimation;
@@ -258,11 +260,33 @@ public class ConversationFragment extends LoggingFragment {
ConversationIntents.Args args = ConversationIntents.Args.from(requireActivity().getIntent());
colorizerView.setBackground(args.getChatColors().getChatBubbleMask());
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
final LinearLayoutManager layoutManager = new SmoothScrollingLinearLayoutManager(getActivity(), true);
final MultiselectItemAnimator multiselectItemAnimator = new MultiselectItemAnimator(() -> {
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
return false;
} else {
return Util.hasItems(adapter.getSelectedItems());
}
}, multiselectPart -> {
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
return false;
} else {
return adapter.getSelectedItems().contains(multiselectPart);
}
});
MultiselectItemDecoration multiselectItemDecoration = new MultiselectItemDecoration(requireContext(),
() -> conversationViewModel.getWallpaper().getValue(),
multiselectItemAnimator::getSelectedProgressForPart,
multiselectItemAnimator::isInitialAnimation);
list.setHasFixedSize(false);
list.setLayoutManager(layoutManager);
list.addItemDecoration(new MultiselectItemDecoration(requireContext(), () -> conversationViewModel.getWallpaper().getValue()));
list.setItemAnimator(null);
list.addItemDecoration(multiselectItemDecoration);
list.setItemAnimator(multiselectItemAnimator);
getViewLifecycleOwner().getLifecycle().addObserver(multiselectItemDecoration);
if (Build.VERSION.SDK_INT >= 31) {
list.setOverScrollMode(View.OVER_SCROLL_NEVER);
@@ -285,13 +309,15 @@ public class ConversationFragment extends LoggingFragment {
MenuState.canReplyToMessage(recipient.get(),
MenuState.isActionMessage(conversationMessage.getMessageRecord()),
conversationMessage.getMessageRecord(),
messageRequestViewModel.shouldShowMessageRequest()),
messageRequestViewModel.shouldShowMessageRequest(),
groupViewModel.isNonAdminInAnnouncementGroup()),
this::handleReplyMessage,
this::onViewHolderPositionTranslated
).attachToRecyclerView(list);
setupListLayoutListeners();
this.groupViewModel = ViewModelProviders.of(requireActivity(), new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
this.messageCountsViewModel = ViewModelProviders.of(requireActivity()).get(MessageCountsViewModel.class);
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
@@ -668,13 +694,14 @@ public class ConversationFragment extends LoggingFragment {
}
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(requireContext(), this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), new AttachmentMediaSourceFactory(requireContext()), colorizer);
ConversationAdapter adapter = new ConversationAdapter(requireContext(), this, GlideApp.with(this), locale, selectionClickListener, this.recipient.get(), colorizer);
adapter.setPagingController(conversationViewModel.getPagingController());
list.setAdapter(adapter);
setInlineDateDecoration(adapter);
ConversationAdapter.initializePool(list.getRecycledViewPool());
adapter.registerAdapterDataObserver(snapToTopDataObserver);
adapter.registerAdapterDataObserver(new CheckExpirationDataObserver());
setLastSeen(conversationViewModel.getLastSeen());
@@ -768,7 +795,7 @@ public class ConversationFragment extends LoggingFragment {
});
}
private void setCorrectMenuVisibility(@NonNull Menu menu) {
private void setCorrectActionModeMenuVisibility(@NonNull Menu menu) {
Set<MultiselectPart> selectedParts = getListAdapter().getSelectedItems();
if (actionMode != null && selectedParts.size() == 0) {
@@ -776,7 +803,7 @@ public class ConversationFragment extends LoggingFragment {
return;
}
MenuState menuState = MenuState.getMenuState(recipient.get(), selectedParts, messageRequestViewModel.shouldShowMessageRequest());
MenuState menuState = MenuState.getMenuState(recipient.get(), selectedParts, messageRequestViewModel.shouldShowMessageRequest(), groupViewModel.isNonAdminInAnnouncementGroup());
menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction());
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
@@ -785,6 +812,8 @@ public class ConversationFragment extends LoggingFragment {
menu.findItem(R.id.menu_context_resend).setVisible(menuState.shouldShowResendAction());
menu.findItem(R.id.menu_context_copy).setVisible(menuState.shouldShowCopyAction());
menu.findItem(R.id.menu_context_delete_message).setVisible(menuState.shouldShowDeleteAction());
AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
}
private @Nullable ConversationAdapter getListAdapter() {
@@ -951,7 +980,7 @@ public class ConversationFragment extends LoggingFragment {
MultiselectForwardFragmentArgs.create(requireContext(),
multiselectParts,
args -> MultiselectForwardFragment.show(getParentFragmentManager(), args));
args -> MultiselectForwardFragment.show(getChildFragmentManager(), args));
}
private void handleResendMessage(final MessageRecord message) {
@@ -1025,7 +1054,6 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0);
getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord, messageRecord.getDisplayBody(requireContext()), message.getMentions()));
list.post(() -> list.scrollToPosition(0));
}
@@ -1038,19 +1066,12 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0);
getListAdapter().addFastRecord(ConversationMessageFactory.createWithResolvedData(messageRecord));
list.post(() -> list.scrollToPosition(0));
}
return messageRecord.getId();
}
public void releaseOutgoingMessage(long id) {
if (getListAdapter() != null) {
getListAdapter().releaseFastRecord(id);
}
}
private void presentConversationMetadata(@NonNull ConversationData conversation) {
ConversationAdapter adapter = getListAdapter();
if (adapter == null) {
@@ -1240,6 +1261,27 @@ public class ConversationFragment extends LoggingFragment {
});
}
private @NonNull String calculateSelectedItemCount() {
ConversationAdapter adapter = getListAdapter();
if (adapter == null || adapter.getSelectedItems().isEmpty()) {
return String.valueOf(0);
}
return String.valueOf(adapter.getSelectedItems()
.stream()
.map(MultiselectPart::getConversationMessage)
.distinct()
.count());
}
@Override
public void onFinishForwardAction() {
if (actionMode != null) {
actionMode.finish();
}
}
public interface ConversationFragmentListener extends VoiceNoteMediaControllerOwner {
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
@@ -1342,8 +1384,8 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter().getSelectedItems().size() == 0) {
actionMode.finish();
} else {
setCorrectMenuVisibility(actionMode.getMenu());
actionMode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size()));
setCorrectActionModeMenuVisibility(actionMode.getMenu());
actionMode.setTitle(calculateSelectedItemCount());
}
}
}
@@ -1611,7 +1653,7 @@ public class ConversationFragment extends LoggingFragment {
.setView(R.layout.safety_number_changed_learn_more_dialog)
.setPositiveButton(R.string.ConversationFragment_verify, (d, w) -> {
SimpleTask.run(getLifecycle(), () -> {
return DatabaseFactory.getIdentityDatabase(requireContext()).getIdentity(recipient.getId());
return ApplicationDependencies.getIdentityStore().getIdentityRecord(recipient.getId());
}, identityRecord -> {
if (identityRecord.isPresent()) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord.get()));
@@ -1706,6 +1748,33 @@ public class ConversationFragment extends LoggingFragment {
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
}
private final class CheckExpirationDataObserver extends RecyclerView.AdapterDataObserver {
@Override
public void onItemRangeRemoved(int positionStart, int itemCount) {
ConversationAdapter adapter = getListAdapter();
if (adapter == null || actionMode == null) {
return;
}
Set<MultiselectPart> selected = adapter.getSelectedItems();
Set<MultiselectPart> expired = new HashSet<>();
for (final MultiselectPart multiselectPart : selected) {
if (multiselectPart.isExpired()) {
expired.add(multiselectPart);
}
}
adapter.removeFromSelection(expired);
if (adapter.getSelectedItems().isEmpty()) {
actionMode.finish();
} else {
actionMode.setTitle(calculateSelectedItemCount());
}
}
}
private final class ConversationSnapToTopDataObserver extends SnapToTopDataObserver {
public ConversationSnapToTopDataObserver(@NonNull RecyclerView recyclerView,
@@ -1793,7 +1862,7 @@ public class ConversationFragment extends LoggingFragment {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.conversation_context, menu);
mode.setTitle(String.valueOf(getListAdapter().getSelectedItems().size()));
mode.setTitle(calculateSelectedItemCount());
if (Build.VERSION.SDK_INT >= 21) {
Window window = getActivity().getWindow();
@@ -1805,8 +1874,7 @@ public class ConversationFragment extends LoggingFragment {
WindowUtil.setLightStatusBar(getActivity().getWindow());
}
setCorrectMenuVisibility(menu);
AdaptiveActionsToolbar.adjustMenuActions(menu, 10, requireActivity().getWindow().getDecorView().getMeasuredWidth());
setCorrectActionModeMenuVisibility(menu);
listener.onMessageActionToolbarOpened();
return true;
}
@@ -1848,7 +1916,6 @@ public class ConversationFragment extends LoggingFragment {
return true;
case R.id.menu_context_forward:
handleForwardMessageParts(getListAdapter().getSelectedItems());
actionMode.finish();
return true;
case R.id.menu_context_resend:
handleResendMessage(getSelectedConversationMessage().getMessageRecord());

View File

@@ -16,8 +16,6 @@
*/
package org.thoughtcrime.securesms.conversation;
import static org.thoughtcrime.securesms.util.ThemeUtil.isDarkTheme;
import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.ActivityNotFoundException;
@@ -62,10 +60,9 @@ import androidx.core.text.util.LinkifyCompat;
import androidx.lifecycle.LifecycleOwner;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.MediaItem;
import com.google.common.collect.Sets;
import org.jetbrains.annotations.NotNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.MediaPreviewActivity;
@@ -128,19 +125,18 @@ import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.UrlClickHandler;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.VibrateUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.NullableStub;
import org.thoughtcrime.securesms.util.views.Stub;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
@@ -180,7 +176,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Nullable private QuoteView quoteView;
private EmojiTextView bodyText;
private ConversationItemFooter footer;
private ConversationItemFooter stickerFooter;
@Nullable private ConversationItemFooter stickerFooter;
@Nullable private TextView groupSender;
@Nullable private View groupSenderHolder;
private AvatarImageView contactPhoto;
@@ -219,7 +215,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final Context context;
private MediaSource mediaSource;
private MediaItem mediaItem;
private boolean canPlayContent;
private Projection.Corners bodyBubbleCorners;
private Colorizer colorizer;
@@ -287,7 +283,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean pulse,
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
@@ -308,7 +303,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.groupThread = conversationRecipient.isGroup();
this.recipient = messageRecord.getIndividualRecipient().live();
this.canPlayContent = false;
this.mediaSource = null;
this.mediaItem = null;
this.colorizer = colorizer;
this.recipient.observeForever(this);
@@ -316,7 +311,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setGutterSizes(messageRecord, groupThread);
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, attachmentMediaSourceFactory, allowedToPlayInline);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, groupThread, hasWallpaper, isMessageRequestAccepted, allowedToPlayInline);
setBodyText(messageRecord, searchQuery, isMessageRequestAccepted);
setBubbleState(messageRecord, messageRecord.getRecipient(), hasWallpaper, colorizer);
setInteractionState(conversationMessage, pulse);
@@ -342,7 +337,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
lastYDownRelativeToThis = ev.getY();
}
return super.onInterceptTouchEvent(ev);
if (batchSelected.isEmpty()) {
return super.onInterceptTouchEvent(ev);
} else {
return true;
}
}
@Override
@@ -387,35 +386,52 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
if (!updatingFooter && !isCaptionlessMms(messageRecord) && !isViewOnceMessage(messageRecord) && isFooterVisible(messageRecord, nextMessageRecord, groupThread)) {
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int defaultTopMargin = readDimen(R.dimen.message_bubble_default_footer_bottom_margin);
int defaultBottomMargin = readDimen(R.dimen.message_bubble_bottom_padding);
int collapsedBottomMargin = readDimen(R.dimen.message_bubble_collapsed_bottom_padding);
if (!updatingFooter &&
getActiveFooter(messageRecord) == footer &&
!hasAudio(messageRecord) &&
isFooterVisible(messageRecord, nextMessageRecord, groupThread) &&
!bodyText.isJumbomoji() &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
int bodyMargins = ViewUtil.getLeftMargin(bodyText) + ViewUtil.getRightMargin(bodyText);
int sizeWithMargins = bodyText.getMeasuredWidth() + ViewUtil.dpToPx(6) + footerWidth + bodyMargins;
int minSize = Math.min(maxBubbleWidth, Math.max(bodyText.getMeasuredWidth() + ViewUtil.dpToPx(6) + footerWidth + bodyMargins, bodyBubble.getMeasuredWidth()));
if (hasQuote(messageRecord) && sizeWithMargins < availableWidth) {
ViewUtil.setTopMargin(footer, readDimen(R.dimen.message_bubble_same_line_footer_bottom_margin));
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
needsMeasure = true;
updatingFooter = true;
} else if (sizeWithMargins != bodyText.getMeasuredWidth() && sizeWithMargins <= minSize) {
bodyBubble.getLayoutParams().width = minSize;
ViewUtil.setTopMargin(footer, readDimen(R.dimen.message_bubble_same_line_footer_bottom_margin));
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
needsMeasure = true;
updatingFooter = true;
}
}
if (!updatingFooter && bodyText.getLastLineWidth() + ViewUtil.dpToPx(6) + footerWidth <= bodyText.getMeasuredWidth()) {
ViewUtil.setTopMargin(footer, readDimen(R.dimen.message_bubble_same_line_footer_bottom_margin));
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
updatingFooter = true;
needsMeasure = true;
} else if (!updatingFooter && ViewUtil.getTopMargin(footer) == readDimen(R.dimen.message_bubble_same_line_footer_bottom_margin)) {
ViewUtil.setTopMargin(footer, readDimen(R.dimen.message_bubble_default_footer_bottom_margin));
needsMeasure = true;
}
}
if (!updatingFooter && ViewUtil.getTopMargin(footer) != defaultTopMargin) {
ViewUtil.setTopMargin(footer, defaultTopMargin);
ViewUtil.setBottomMargin(footer, defaultBottomMargin);
needsMeasure = true;
}
if (hasSharedContact(messageRecord)) {
int contactWidth = sharedContactStub.get().getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(sharedContactStub.get());
@@ -482,7 +498,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private int getMaxBubbleWidth() {
int paddings = getPaddingLeft() + getPaddingRight() + ViewUtil.getLeftMargin(bodyBubble) + ViewUtil.getRightMargin(bodyBubble);
if (groupThread && !messageRecord.isOutgoing()) {
if (groupThread && !messageRecord.isOutgoing() && !messageRecord.isRemoteDelete()) {
paddings += contactPhoto.getLayoutParams().width + ViewUtil.getLeftMargin(contactPhoto) + ViewUtil.getRightMargin(contactPhoto);
}
return getMeasuredWidth() - paddings;
@@ -520,45 +536,69 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
MultiselectPart bottom = parts.asDouble().getBottomPart();
if (hasThumbnail(messageRecord)) {
Projection thumbnailProjection = Projection.relativeToParent(this, mediaThumbnailStub.require(), null);
float mediaBoundary = thumbnailProjection.getY() + thumbnailProjection.getHeight();
if (lastYDownRelativeToThis > mediaBoundary) {
return bottom;
} else {
return top;
}
} else {
throw new IllegalStateException("Found a situation where we have something other than a thumbnail.");
return isTouchBelowBoundary(mediaThumbnailStub.require()) ? bottom : top;
} else if (hasDocument(messageRecord)) {
return isTouchBelowBoundary(documentViewStub.get()) ? bottom : top;
} else if (hasAudio(messageRecord)) {
return isTouchBelowBoundary(audioViewStub.get()) ? bottom : top;
} {
throw new IllegalStateException("Found a situation where we have something other than a thumbnail or a document.");
}
}
private boolean isTouchBelowBoundary(@NonNull View child) {
Projection childProjection = Projection.relativeToParent(this, child, null);
float childBoundary = childProjection.getY() + childProjection.getHeight();
return lastYDownRelativeToThis > childBoundary;
}
@Override
public int getTopBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) {
if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) {
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
return (int) projection.getY();
} else if (multiselectPart instanceof MultiselectPart.Text && hasThumbnail(messageRecord)) {
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
return (int) projection.getY() + projection.getHeight();
boolean isTextPart = multiselectPart instanceof MultiselectPart.Text;
boolean isAttachmentPart = multiselectPart instanceof MultiselectPart.Attachments;
if (hasThumbnail(messageRecord) && isAttachmentPart) {
return getProjectionTop(mediaThumbnailStub.require());
} else if (hasThumbnail(messageRecord) && isTextPart) {
return getProjectionBottom(mediaThumbnailStub.require());
} else if (hasDocument(messageRecord) && isAttachmentPart) {
return getProjectionTop(documentViewStub.get());
} else if (hasDocument(messageRecord) && isTextPart) {
return getProjectionBottom(documentViewStub.get());
} else if (hasAudio(messageRecord) && isAttachmentPart) {
return getProjectionTop(audioViewStub.get());
} else if (hasAudio(messageRecord) && isTextPart) {
return getProjectionBottom(audioViewStub.get());
} else if (hasNoBubble(messageRecord)) {
return getTop();
} else {
Projection projection = Projection.relativeToViewRoot(bodyBubble, null);
return (int) projection.getY();
return getProjectionTop(bodyBubble);
}
}
private static int getProjectionTop(@NonNull View child) {
return (int) Projection.relativeToViewRoot(child, null).getY();
}
private static int getProjectionBottom(@NonNull View child) {
Projection projection = Projection.relativeToViewRoot(child, null);
return (int) projection.getY() + projection.getHeight();
}
@Override
public int getBottomBoundaryOfMultiselectPart(@NonNull MultiselectPart multiselectPart) {
if (multiselectPart instanceof MultiselectPart.Attachments && hasThumbnail(messageRecord)) {
Projection projection = Projection.relativeToViewRoot(mediaThumbnailStub.require(), null);
return (int) projection.getY() + projection.getHeight();
return getProjectionBottom(mediaThumbnailStub.require());
} else if (multiselectPart instanceof MultiselectPart.Attachments && hasDocument(messageRecord)) {
return getProjectionBottom(documentViewStub.get());
} else if (multiselectPart instanceof MultiselectPart.Attachments && hasAudio(messageRecord)) {
return getProjectionBottom(audioViewStub.get());
} else if (hasNoBubble(messageRecord)) {
return getBottom();
} else {
Projection projection = Projection.relativeToViewRoot(bodyBubble, null);
return (int) projection.getY() + projection.getHeight();
return getProjectionBottom(bodyBubble);
}
}
@@ -568,7 +608,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public ConversationMessage getConversationMessage() {
public @NonNull ConversationMessage getConversationMessage() {
return conversationMessage;
}
@@ -796,8 +836,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRequestAccepted) {
linkifyMessageBody(styledText, batchSelected.isEmpty());
}
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new BackgroundColorSpan(Color.YELLOW), styledText, searchQuery, SearchUtil.STRICT);
styledText = SearchUtil.getHighlightedSpan(locale, () -> new ForegroundColorSpan(Color.BLACK), styledText, searchQuery, SearchUtil.STRICT);
if (hasExtraText(messageRecord)) {
bodyText.setOverflowText(getLongMessageSpan(messageRecord));
@@ -808,7 +848,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRecord.isOutgoing()) {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, R.color.transparent_black_25));
} else {
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
bodyText.setMentionBackgroundTint(ContextCompat.getColor(context, ThemeUtil.isDarkTheme(context) ? R.color.core_grey_60 : R.color.core_grey_20));
}
bodyText.setText(StringUtil.trim(styledText));
@@ -822,7 +862,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean isGroupThread,
boolean hasWallpaper,
boolean messageRequestAccepted,
@Nullable AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline)
{
boolean showControls = !messageRecord.isFailed();
@@ -1029,8 +1068,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
footer.setVisibility(VISIBLE);
if (attachmentMediaSourceFactory != null &&
thumbnailSlides.size() == 1 &&
if (thumbnailSlides.size() == 1 &&
thumbnailSlides.get(0).isVideoGif() &&
thumbnailSlides.get(0) instanceof VideoSlide)
{
@@ -1038,9 +1076,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
Uri uri = thumbnailSlides.get(0).getUri();
if (uri != null) {
mediaSource = attachmentMediaSourceFactory.createMediaSource(uri);
mediaItem = MediaItem.fromUri(uri);
} else {
mediaSource = null;
mediaItem = null;
}
}
@@ -1335,7 +1373,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
ViewUtil.setTopMargin(footer, readDimen(R.dimen.message_bubble_default_footer_bottom_margin));
footer.setVisibility(GONE);
stickerFooter.setVisibility(GONE);
ViewUtil.setVisibilityIfNonNull(stickerFooter, GONE);
if (sharedContactStub.resolved()) sharedContactStub.get().getFooter().setVisibility(GONE);
if (mediaThumbnailStub.resolved()) mediaThumbnailStub.require().getFooter().setVisibility(GONE);
@@ -1370,7 +1408,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
if (hasNoBubble(messageRecord)) {
if (hasNoBubble(messageRecord) && stickerFooter != null) {
return stickerFooter;
} else if (hasSharedContact(messageRecord) && TextUtils.isEmpty(messageRecord.getDisplayBody(getContext()))) {
return sharedContactStub.get().getFooter();
@@ -1631,8 +1669,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
@Override
public @Nullable MediaSource getMediaSource() {
return mediaSource;
public @Nullable MediaItem getMediaItem() {
return mediaItem;
}
@Override
@@ -1694,12 +1732,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
quoteView != null)
{
bodyBubble.setQuoteViewProjection(quoteView.getProjection(bodyBubble));
projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX()));
projections.add(quoteView.getProjection((ViewGroup) getRootView()).translateX(bodyBubble.getTranslationX() + this.getTranslationX()));
}
return projections;
}
@Override
public @Nullable View getHorizontalTranslationTarget() {
if (messageRecord.isOutgoing()) {
return null;
} else if (groupThread) {
return contactPhotoHolder;
} else {
return bodyBubble;
}
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {
@@ -1827,7 +1876,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
public void onClick(final View v, final Slide slide) {
if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) {
performClick();
} else if (!canPlayContent && mediaSource != null && eventListener != null) {
} else if (!canPlayContent && mediaItem != null && eventListener != null) {
eventListener.onPlayInlineContent(conversationMessage);
} else if (MediaPreviewActivity.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
Intent intent = new Intent(context, MediaPreviewActivity.class);
@@ -1905,7 +1954,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final class TouchDelegateChangedListener implements ConversationItemFooter.OnTouchDelegateChangedListener {
@Override
public void onTouchDelegateChanged(@NonNull @NotNull Rect delegateRect, @NonNull @NotNull View delegateView) {
public void onTouchDelegateChanged(@NonNull Rect delegateRect, @NonNull View delegateView) {
offsetDescendantRectToMyCoords(footer, delegateRect);
setTouchDelegate(new TouchDelegate(delegateRect, delegateView));
}

View File

@@ -41,9 +41,10 @@ final class ConversationReactionDelegate {
@NonNull MaskView.MaskTarget maskTarget,
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
int maskPaddingBottom)
int maskPaddingBottom,
boolean isNonAdminInAnnouncementGroup)
{
resolveOverlay().show(activity, maskTarget, conversationRecipient, conversationMessage, maskPaddingBottom, lastSeenDownPoint);
resolveOverlay().show(activity, maskTarget, conversationRecipient, conversationMessage, maskPaddingBottom, lastSeenDownPoint, isNonAdminInAnnouncementGroup);
}
void showMask(@NonNull MaskView.MaskTarget maskTarget, int maskPaddingTop, int maskPaddingBottom) {

View File

@@ -60,6 +60,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private Recipient conversationRecipient;
private MessageRecord messageRecord;
private OverlayState overlayState = OverlayState.HIDDEN;
private boolean isNonAdminInAnnouncementGroup;
private boolean downIsOurs;
private boolean isToolbarTouch;
@@ -152,17 +153,18 @@ public final class ConversationReactionOverlay extends RelativeLayout {
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
int maskPaddingBottom,
@NonNull PointF lastSeenDownPoint)
@NonNull PointF lastSeenDownPoint,
boolean isNonAdminInAnnouncementGroup)
{
if (overlayState != OverlayState.HIDDEN) {
return;
}
this.messageRecord = conversationMessage.getMessageRecord();
this.conversationRecipient = conversationRecipient;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
this.messageRecord = conversationMessage.getMessageRecord();
this.conversationRecipient = conversationRecipient;
this.isNonAdminInAnnouncementGroup = isNonAdminInAnnouncementGroup;
overlayState = OverlayState.UNINITAILIZED;
selected = -1;
setupToolbarMenuItems(conversationMessage);
setupSelectedEmoji();
@@ -505,7 +507,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
}
private void setupToolbarMenuItems(@NonNull ConversationMessage conversationMessage) {
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false);
MenuState menuState = MenuState.getMenuState(conversationRecipient, conversationMessage.getMultiselectCollection().toSet(), false, isNonAdminInAnnouncementGroup);
toolbar.getMenu().findItem(R.id.action_copy).setVisible(menuState.shouldShowCopyAction());
toolbar.getMenu().findItem(R.id.action_download).setVisible(menuState.shouldShowSaveAttachmentAction());

View File

@@ -2,11 +2,9 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.Point;
import android.text.Spannable;
import android.text.SpannableString;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
@@ -27,10 +25,9 @@ import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil;
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
@@ -48,7 +45,6 @@ import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collection;
@@ -116,7 +112,6 @@ public final class ConversationUpdateItem extends FrameLayout
boolean pulseMention,
boolean hasWallpaper,
boolean isMessageRequestAccepted,
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
boolean allowedToPlayInline,
@NonNull Colorizer colorizer)
{
@@ -131,7 +126,7 @@ public final class ConversationUpdateItem extends FrameLayout
}
@Override
public ConversationMessage getConversationMessage() {
public @NonNull ConversationMessage getConversationMessage() {
return conversationMessage;
}
@@ -230,6 +225,11 @@ public final class ConversationUpdateItem extends FrameLayout
return Collections.emptyList();
}
@Override
public @Nullable View getHorizontalTranslationTarget() {
return background;
}
static final class RecipientObserverManager {
private final Observer<Recipient> recipientObserver;

View File

@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.conversation.colors.NameColor;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
@@ -64,8 +65,10 @@ public class ConversationViewModel extends ViewModel {
private final MutableLiveData<Boolean> showScrollButtons;
private final MutableLiveData<Boolean> hasUnreadMentions;
private final LiveData<Boolean> canShowAsBubble;
private final ProxyPagingController pagingController;
private final DatabaseObserver.Observer messageObserver;
private final ProxyPagingController<MessageId> pagingController;
private final DatabaseObserver.Observer conversationObserver;
private final DatabaseObserver.MessageObserver messageUpdateObserver;
private final DatabaseObserver.MessageObserver messageInsertObserver;
private final MutableLiveData<RecipientId> recipientId;
private final LiveData<ChatWallpaper> wallpaper;
private final SingleLiveEvent<Event> events;
@@ -89,8 +92,10 @@ public class ConversationViewModel extends ViewModel {
this.hasUnreadMentions = new MutableLiveData<>(false);
this.recipientId = new MutableLiveData<>();
this.events = new SingleLiveEvent<>();
this.pagingController = new ProxyPagingController();
this.messageObserver = pagingController::onDataInvalidated;
this.pagingController = new ProxyPagingController<>();
this.conversationObserver = pagingController::onDataInvalidated;
this.messageUpdateObserver = pagingController::onDataItemChanged;
this.messageInsertObserver = messageId -> pagingController.onDataItemInserted(messageId, 0);
this.toolbarBottom = new MutableLiveData<>();
this.inlinePlayerHeight = new MutableLiveData<>();
this.scrollDateTopMargin = Transformations.distinctUntilChanged(LiveDataUtil.combineLatest(toolbarBottom, inlinePlayerHeight, Integer::sum));
@@ -106,7 +111,9 @@ public class ConversationViewModel extends ViewModel {
return conversationData;
});
LiveData<Pair<Long, PagedData<ConversationMessage>>> pagedDataForThreadId = Transformations.map(metadata, data -> {
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver);
LiveData<Pair<Long, PagedData<MessageId, ConversationMessage>>> pagedDataForThreadId = Transformations.map(metadata, data -> {
int startPosition;
ConversationData.MessageRequestData messageRequestData = data.getMessageRequestData();
@@ -120,8 +127,10 @@ public class ConversationViewModel extends ViewModel {
startPosition = data.getThreadSize();
}
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver);
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), messageObserver);
ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver);
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver);
ApplicationDependencies.getDatabaseObserver().registerConversationObserver(data.getThreadId(), conversationObserver);
ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(data.getThreadId(), messageInsertObserver);
ConversationDataSource dataSource = new ConversationDataSource(context, data.getThreadId(), messageRequestData, data.showUniversalExpireTimerMessage());
PagingConfig config = new PagingConfig.Builder().setPageSize(25)
@@ -292,7 +301,9 @@ public class ConversationViewModel extends ViewModel {
@Override
protected void onCleared() {
super.onCleared();
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver);
ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver);
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageUpdateObserver);
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver);
EventBus.getDefault().unregister(this);
}

View File

@@ -14,6 +14,8 @@ import java.util.stream.Collectors;
final class MenuState {
private static final int MAX_FORWARDABLE_COUNT = 32;
private final boolean forward;
private final boolean reply;
private final boolean details;
@@ -62,7 +64,8 @@ final class MenuState {
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest)
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
Builder builder = new Builder();
@@ -114,7 +117,7 @@ final class MenuState {
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
((FeatureFlags.forwardMultipleMessages() && selectedParts.size() <= 5) || selectedParts.size() == 1);
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
int uniqueRecords = selectedParts.stream()
.map(MultiselectPart::getMessageRecord)
@@ -141,7 +144,7 @@ final class MenuState {
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage)
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest));
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText)
@@ -156,8 +159,14 @@ final class MenuState {
.allMatch(collection -> multiselectParts.containsAll(collection.toSet()));
}
static boolean canReplyToMessage(@NonNull Recipient conversationRecipient, boolean actionMessage, @NonNull MessageRecord messageRecord, boolean isDisplayingMessageRequest) {
static boolean canReplyToMessage(@NonNull Recipient conversationRecipient,
boolean actionMessage,
@NonNull MessageRecord messageRecord,
boolean isDisplayingMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
return !actionMessage &&
!isNonAdminInAnnouncementGroup &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isPending() &&
!messageRecord.isFailed() &&

View File

@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.mms.TextSlide
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.Util
/**
@@ -29,10 +28,6 @@ object Multiselect {
fun getParts(conversationMessage: ConversationMessage): MultiselectCollection {
val messageRecord = conversationMessage.messageRecord
if (!FeatureFlags.forwardMultipleMessages()) {
return MultiselectCollection.Single(MultiselectPart.Message(conversationMessage))
}
if (messageRecord.isUpdate) {
return MultiselectCollection.Single(MultiselectPart.Update(conversationMessage))
}

View File

@@ -35,6 +35,8 @@ sealed class MultiselectCollection {
}
}
fun isExpired(): Boolean = toSet().any(MultiselectPart::isExpired)
fun isTextSelected(selectedParts: Set<MultiselectPart>): Boolean {
val textParts: Set<MultiselectPart> = toSet().filter(this::couldContainText).toSet()

View File

@@ -0,0 +1,154 @@
package org.thoughtcrime.securesms.conversation.mutiselect
import android.animation.ValueAnimator
import androidx.core.animation.doOnEnd
import androidx.recyclerview.widget.RecyclerView
/**
* Class for managing the triggering of item animations (here in the form of decoration redraws) whenever
* there is a "selection" edge detected.
*
* Can be expanded upon in the future to animate other things, such as message sends.
*/
class MultiselectItemAnimator(
private val isInMultiSelectMode: () -> Boolean,
private val isPartSelected: (MultiselectPart) -> Boolean
) : RecyclerView.ItemAnimator() {
private data class Selection(
val multiselectPart: MultiselectPart,
val viewHolder: RecyclerView.ViewHolder
)
var isInitialAnimation: Boolean = true
private set
private val selected: MutableSet<MultiselectPart> = mutableSetOf()
private val pendingSelectedAnimations: MutableSet<Selection> = mutableSetOf()
private val selectedAnimations: MutableMap<Selection, ValueAnimator> = mutableMapOf()
fun getSelectedProgressForPart(multiselectPart: MultiselectPart): Float {
return if (pendingSelectedAnimations.any { it.multiselectPart == multiselectPart }) {
0f
} else {
selectedAnimations.filter { it.key.multiselectPart == multiselectPart }.values.firstOrNull()?.animatedFraction ?: 1f
}
}
override fun animateDisappearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo?): Boolean {
dispatchAnimationFinished(viewHolder)
return false
}
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
dispatchAnimationFinished(viewHolder)
return false
}
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
dispatchAnimationFinished(viewHolder)
return false
}
override fun animateChange(oldHolder: RecyclerView.ViewHolder, newHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
if (oldHolder != newHolder) {
dispatchAnimationFinished(oldHolder)
}
val isInMultiSelectMode = isInMultiSelectMode()
if (!isInMultiSelectMode) {
selected.clear()
isInitialAnimation = true
dispatchAnimationFinished(newHolder)
return false
}
var isAnimationStarted = false
val parts: MultiselectCollection? = (newHolder.itemView as? Multiselectable)?.conversationMessage?.multiselectCollection
if (parts == null || parts.isExpired()) {
dispatchAnimationFinished(newHolder)
return false
}
parts.toSet().forEach { part ->
val partIsSelected = isPartSelected(part)
if (selected.contains(part) && !partIsSelected) {
pendingSelectedAnimations.add(Selection(part, newHolder))
selected.remove(part)
isAnimationStarted = true
} else if (!selected.contains(part) && partIsSelected) {
pendingSelectedAnimations.add(Selection(part, newHolder))
selected.add(part)
isAnimationStarted = true
} else if (isInitialAnimation) {
pendingSelectedAnimations.add(Selection(part, newHolder))
isAnimationStarted = true
}
}
if (isAnimationStarted) {
dispatchAnimationStarted(newHolder)
} else {
dispatchAnimationFinished(newHolder)
}
return isAnimationStarted
}
override fun runPendingAnimations() {
for (selection in pendingSelectedAnimations) {
val animator = ValueAnimator.ofFloat(0f, 1f)
selectedAnimations[selection] = animator
animator.duration = 150L
animator.addUpdateListener {
(selection.viewHolder.itemView.parent as RecyclerView).invalidateItemDecorations()
}
animator.doOnEnd {
dispatchAnimationFinished(selection.viewHolder)
selectedAnimations.remove(selection)
isInitialAnimation = false
}
animator.start()
}
pendingSelectedAnimations.clear()
}
override fun endAnimation(item: RecyclerView.ViewHolder) {
endSelectedAnimation(item)
}
override fun endAnimations() {
endSelectedAnimations()
dispatchAnimationsFinished()
}
override fun isRunning(): Boolean {
return selectedAnimations.values.any { it.isRunning }
}
override fun onAnimationFinished(viewHolder: RecyclerView.ViewHolder) {
dispatchItemDecorationRedraw(viewHolder)
}
private fun dispatchItemDecorationRedraw(viewHolder: RecyclerView.ViewHolder) {
val parent = (viewHolder.itemView.parent as RecyclerView)
parent.post { parent.invalidateItemDecorations() }
}
private fun endSelectedAnimation(item: RecyclerView.ViewHolder) {
val selections = selectedAnimations.filter { (k, _) -> k.viewHolder == item }
selections.forEach { (k, v) ->
v.end()
selectedAnimations.remove(k)
}
}
fun endSelectedAnimations() {
selectedAnimations.values.forEach { it.end() }
selectedAnimations.clear()
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.conversation.mutiselect
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
@@ -11,6 +12,8 @@ import android.view.View
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.view.children
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.lottie.SimpleColorFilter
import org.thoughtcrime.securesms.R
@@ -20,11 +23,17 @@ import org.thoughtcrime.securesms.util.SetUtil
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.lang.Integer.max
/**
* Decoration which renders the background shade and selection bubble for a {@link Multiselectable} item.
*/
class MultiselectItemDecoration(context: Context, private val chatWallpaperProvider: () -> ChatWallpaper?) : RecyclerView.ItemDecoration() {
class MultiselectItemDecoration(
context: Context,
private val chatWallpaperProvider: () -> ChatWallpaper?,
private val selectedAnimationProgressProvider: (MultiselectPart) -> Float,
private val isInitialAnimation: () -> Boolean
) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
private val path = Path()
private val rect = Rect()
@@ -43,6 +52,21 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
private val ultramarine30 = ContextCompat.getColor(context, R.color.core_ultramarine_33)
private val ultramarine = ContextCompat.getColor(context, R.color.signal_accent_primary)
private var checkedBitmap: Bitmap? = null
override fun onCreate(owner: LifecycleOwner) {
val bitmap = Bitmap.createBitmap(circleRadius * 2, circleRadius * 2, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
checkDrawable.draw(canvas)
checkedBitmap = bitmap
}
override fun onDestroy(owner: LifecycleOwner) {
checkedBitmap?.recycle()
checkedBitmap = null
}
private val unselectedPaint = Paint().apply {
isAntiAlias = true
strokeWidth = 1.5f
@@ -60,20 +84,14 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
color = transparentBlack20
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
val adapter = parent.adapter as ConversationAdapter
val isLtr = ViewUtil.isLtr(view)
private val checkPaint = Paint().apply {
isAntiAlias = true
style = Paint.Style.FILL
}
if (adapter.selectedItems.isNotEmpty() && view is Multiselectable) {
outRect.set(
if (isLtr) gutter else 0,
0,
if (isLtr) 0 else gutter,
0
)
} else {
outRect.setEmpty()
}
override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) {
outRect.setEmpty()
updateChildOffsets(parent, view)
}
/**
@@ -94,6 +112,8 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
}
parent.children.filterIsInstance(Multiselectable::class.java).forEach { child ->
updateChildOffsets(parent, child as View)
val parts: MultiselectCollection = child.conversationMessage.multiselectCollection
val projections: List<Projection> = child.colorizerProjections
@@ -103,7 +123,6 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
canvas.save()
canvas.clipPath(path, Region.Op.DIFFERENCE)
val view: View = child as View
val selectedParts: Set<MultiselectPart> = SetUtil.intersection(parts.toSet(), adapter.selectedItems)
if (selectedParts.isNotEmpty()) {
@@ -111,7 +130,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
val shadeAll = selectedParts.size == parts.size || (selectedPart is MultiselectPart.Text && child.hasNonSelectableMedia())
if (shadeAll) {
rect.set(0, view.top, parent.right, view.bottom)
rect.set(0, child.top, child.right, child.bottom)
} else {
rect.set(0, child.getTopBoundaryOfMultiselectPart(selectedPart), parent.right, child.getBottomBoundaryOfMultiselectPart(selectedPart))
}
@@ -144,9 +163,9 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
}
if (chatWallpaperProvider() == null && !isDarkTheme) {
checkDrawable.colorFilter = SimpleColorFilter(ultramarine)
checkPaint.colorFilter = SimpleColorFilter(ultramarine)
} else {
checkDrawable.clearColorFilter()
checkPaint.colorFilter = null
}
multiselectChildren.forEach { child ->
@@ -159,10 +178,15 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
drawPhotoCircle(canvas, parent, topBoundary, bottomBoundary)
}
val alphaProgress = selectedAnimationProgressProvider(it)
if (adapter.selectedItems.contains(it)) {
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary)
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress)
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress)
} else {
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary)
drawUnselectedCircle(canvas, parent, topBoundary, bottomBoundary, alphaProgress)
if (!isInitialAnimation()) {
drawSelectedCircle(canvas, parent, topBoundary, bottomBoundary, 1f - alphaProgress)
}
}
}
}
@@ -187,7 +211,7 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
/**
* Draws the checkmark for selected content
*/
private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) {
private fun drawSelectedCircle(canvas: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) {
val topX: Float = if (ViewUtil.isLtr(parent)) {
paddingStart
} else {
@@ -195,25 +219,70 @@ class MultiselectItemDecoration(context: Context, private val chatWallpaperProvi
}.toFloat()
val topY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2 - circleRadius
val bitmap = checkedBitmap
canvas.save()
canvas.translate(topX, topY)
checkDrawable.draw(canvas)
canvas.restore()
val alpha = checkPaint.alpha
checkPaint.alpha = (alpha * alphaProgress).toInt()
if (bitmap != null) {
canvas.drawBitmap(bitmap, topX, topY, checkPaint)
}
checkPaint.alpha = alpha
}
/**
* Draws the empty circle for unselected content
*/
private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int) {
private fun drawUnselectedCircle(c: Canvas, parent: RecyclerView, topBoundary: Int, bottomBoundary: Int, alphaProgress: Float) {
val centerX: Float = if (ViewUtil.isLtr(parent)) {
paddingStart + circleRadius
} else {
parent.right - circleRadius - paddingStart
}.toFloat()
val alpha = unselectedPaint.alpha
unselectedPaint.alpha = (alpha * alphaProgress).toInt()
val centerY: Float = topBoundary + (bottomBoundary - topBoundary).toFloat() / 2
c.drawCircle(centerX, centerY, circleRadius.toFloat(), unselectedPaint)
unselectedPaint.alpha = alpha
}
/**
* Update the start-aligned gutter in which the checks display. This is called in onDraw to
* ensure we don't hit situations where we try to set offsets before items are laid out, and
* called in getItemOffsets to ensure the gutter goes away when multiselect mode ends.
*/
private fun updateChildOffsets(parent: RecyclerView, child: View) {
val adapter = parent.adapter as ConversationAdapter
val isLtr = ViewUtil.isLtr(child)
if (adapter.selectedItems.isNotEmpty() && child is Multiselectable) {
val firstPart = child.conversationMessage.multiselectCollection.toSet().first()
val target = child.getHorizontalTranslationTarget()
if (target != null) {
val start = if (isLtr) {
target.left
} else {
parent.right - target.right
}
val translation: Float = if (isInitialAnimation()) {
max(0, gutter - start) * selectedAnimationProgressProvider(firstPart)
} else {
max(0, gutter - start).toFloat()
}
child.translationX = if (isLtr) {
translation
} else {
-translation
}
}
} else if (child is Multiselectable) {
child.translationX = 0f
}
}
}

View File

@@ -10,6 +10,12 @@ sealed class MultiselectPart(open val conversationMessage: ConversationMessage)
fun getMessageRecord(): MessageRecord = conversationMessage.messageRecord
fun isExpired(): Boolean {
val expiresAt = conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn
return expiresAt > 0 && expiresAt < System.currentTimeMillis()
}
/**
* Represents the body of the message
*/

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.mutiselect
import android.view.View
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
@@ -12,5 +13,7 @@ interface Multiselectable : Colorizable {
fun getMultiselectPartForLatestTouch(): MultiselectPart
fun getHorizontalTranslationTarget(): View?
fun hasNonSelectableMedia(): Boolean
}

View File

@@ -1,48 +1,75 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.AnimationUtils
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.TextView
import android.widget.Toast
import androidx.annotation.PluralsRes
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.ContactSelectionListFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ContactFilterView
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.keyboard.findListener
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
import org.thoughtcrime.securesms.util.visible
import org.whispersystems.libsignal.util.guava.Optional
import java.util.function.Consumer
private const val ARG_MULTISHARE_ARGS = "multiselect.forward.fragment.arg.multishare.args"
private const val ARG_CAN_SEND_TO_NON_PUSH = "multiselect.forward.fragment.arg.can.send.to.non.push"
private const val ARG_TITLE = "multiselect.forward.fragment.title"
private val TAG = Log.tag(MultiselectForwardFragment::class.java)
class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment(), ContactSelectionListFragment.OnContactSelectedListener, ContactSelectionListFragment.OnSelectionLimitReachedListener {
class MultiselectForwardFragment :
FixedRoundedCornerBottomSheetDialogFragment(),
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.OnSelectionLimitReachedListener,
SafetyNumberChangeDialog.Callback {
override val peekHeightPercentage: Float = 0.67f
private val viewModel: MultiselectForwardViewModel by viewModels(factoryProducer = this::createViewModelFactory)
private val disposables = LifecycleDisposable()
private lateinit var selectionFragment: ContactSelectionListFragment
private lateinit var contactFilterView: ContactFilterView
private lateinit var addMessage: EditText
private var callback: Callback? = null
private var dismissibleDialog: SimpleProgressDialog.DismissibleDialog? = null
private var handler: Handler? = null
private fun createViewModelFactory(): MultiselectForwardViewModel.Factory {
return MultiselectForwardViewModel.Factory(getMultiShareArgs(), MultiselectForwardRepository(requireContext()))
@@ -73,6 +100,9 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
callback = findListener()
disposables.bindTo(viewLifecycleOwner.lifecycle)
selectionFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
contactFilterView = view.findViewById(R.id.contact_filter_edit_text)
@@ -91,17 +121,19 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
}
}
val title: TextView = view.findViewById(R.id.title)
val container = view.parent.parent.parent as FrameLayout
val bottomBar = LayoutInflater.from(requireContext()).inflate(R.layout.multiselect_forward_fragment_bottom_bar, container, false)
val shareSelectionRecycler: RecyclerView = bottomBar.findViewById(R.id.selected_list)
val shareSelectionAdapter = ShareSelectionAdapter()
val sendButton: View = bottomBar.findViewById(R.id.share_confirm)
val addMessage: EditText = bottomBar.findViewById(R.id.add_message)
val addMessageWrapper: View = bottomBar.findViewById(R.id.add_message_wrapper)
addMessageWrapper.visible = FeatureFlags.forwardMultipleMessages()
title.setText(requireArguments().getInt(ARG_TITLE))
addMessage = bottomBar.findViewById(R.id.add_message)
sendButton.setOnClickListener {
sendButton.isEnabled = false
viewModel.send(addMessage.text.toString())
}
@@ -124,26 +156,116 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
}
viewModel.state.observe(viewLifecycleOwner) {
val toastTextResId: Int? = when (it.stage) {
MultiselectForwardState.Stage.SELECTION -> null
MultiselectForwardState.Stage.SOME_FAILED -> R.string.MultiselectForwardFragment__messages_sent
MultiselectForwardState.Stage.ALL_FAILED -> R.string.MultiselectForwardFragment__messages_failed_to_send
MultiselectForwardState.Stage.SUCCESS -> R.string.MultiselectForwardFragment__messages_sent
when (it.stage) {
MultiselectForwardState.Stage.Selection -> { }
MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation()
is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities)
MultiselectForwardState.Stage.LoadingIdentities -> {}
MultiselectForwardState.Stage.SendPending -> {
handler?.removeCallbacksAndMessages(null)
dismissibleDialog?.dismiss()
dismissibleDialog = SimpleProgressDialog.showDelayed(requireContext())
}
MultiselectForwardState.Stage.SomeFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
MultiselectForwardState.Stage.AllFailed -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_failed_to_send)
MultiselectForwardState.Stage.Success -> dismissAndShowToast(R.plurals.MultiselectForwardFragment_messages_sent)
is MultiselectForwardState.Stage.SelectionConfirmed -> dismissWithResult(it.stage.recipients)
}
if (toastTextResId != null) {
Toast.makeText(requireContext(), toastTextResId, Toast.LENGTH_SHORT).show()
dismissAllowingStateLoss()
}
sendButton.isEnabled = it.stage == MultiselectForwardState.Stage.Selection
}
bottomBar.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
selectionFragment.setRecyclerViewPaddingBottom(bottom - top)
}
addMessage.visible = getMultiShareArgs().isNotEmpty()
}
override fun onResume() {
super.onResume()
val now = System.currentTimeMillis()
val expiringMessages = getMultiShareArgs().filter { it.expiresAt > 0L }
val firstToExpire = expiringMessages.minByOrNull { it.expiresAt }
val earliestExpiration = firstToExpire?.expiresAt ?: -1L
if (earliestExpiration > 0) {
if (earliestExpiration <= now) {
handleMessageExpired()
} else {
handler = Handler(Looper.getMainLooper())
handler?.postDelayed(this::handleMessageExpired, earliestExpiration - now)
}
}
}
override fun onPause() {
super.onPause()
handler?.removeCallbacksAndMessages(null)
}
private fun displayFirstSendConfirmation() {
SignalStore.tooltips().markMultiForwardDialogSeen()
val messageCount = getMessageCount()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MultiselectForwardFragment__faster_forwards)
.setMessage(R.string.MultiselectForwardFragment__forwarded_messages_are_now)
.setPositiveButton(resources.getQuantityString(R.plurals.MultiselectForwardFragment_send_d_messages, messageCount, messageCount)) { d, _ ->
d.dismiss()
viewModel.confirmFirstSend(addMessage.text.toString())
}
.setNegativeButton(android.R.string.cancel) { d, _ ->
d.dismiss()
viewModel.cancelSend()
}
.show()
}
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>) {
SafetyNumberChangeDialog.show(childFragmentManager, identityRecords)
}
private fun dismissAndShowToast(@PluralsRes toastTextResId: Int) {
val argCount = getMessageCount()
callback?.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), requireContext().resources.getQuantityString(toastTextResId, argCount), Toast.LENGTH_SHORT).show()
dismissAllowingStateLoss()
}
private fun dismissWithResult(recipientIds: List<RecipientId>) {
callback?.onFinishForwardAction()
dismissibleDialog?.dismiss()
setFragmentResult(
RESULT_SELECTION,
Bundle().apply {
putParcelableArrayList(RESULT_SELECTION_RECIPIENTS, ArrayList(recipientIds))
}
)
dismissAllowingStateLoss()
}
private fun getMessageCount(): Int = getMultiShareArgs().size + if (addMessage.text.isNotEmpty()) 1 else 0
private fun handleMessageExpired() {
dismissAllowingStateLoss()
callback?.onFinishForwardAction()
dismissibleDialog?.dismiss()
Toast.makeText(requireContext(), resources.getQuantityString(R.plurals.MultiselectForwardFragment__couldnt_forward_messages, getMultiShareArgs().size), Toast.LENGTH_LONG).show()
}
private fun getDefaultDisplayMode(): Int {
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or ContactsCursorLoader.DisplayMode.FLAG_SELF or ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
ContactsCursorLoader.DisplayMode.FLAG_SELF or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
if (Util.isDefaultSmsProvider(requireContext()) && requireArguments().getBoolean(ARG_CAN_SEND_TO_NON_PUSH)) {
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
@@ -154,9 +276,18 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String?, callback: Consumer<Boolean>) {
if (recipientId.isPresent) {
viewModel.addSelectedContact(recipientId, null)
callback.accept(true)
contactFilterView.clear()
disposables.add(
viewModel.addSelectedContact(recipientId, null)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { success ->
if (!success) {
Toast.makeText(requireContext(), R.string.ShareActivity_you_do_not_have_permission_to_send_to_this_group, Toast.LENGTH_SHORT).show()
}
callback.accept(success)
contactFilterView.clear()
}
)
} else {
Log.w(TAG, "Rejecting non-present recipient. Can't forward to an unknown contact.")
callback.accept(false)
@@ -177,7 +308,23 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
Toast.makeText(requireContext(), R.string.MultiselectForwardFragment__limit_reached, Toast.LENGTH_SHORT).show()
}
override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList<RecipientId>) {
viewModel.confirmSafetySend(addMessage.text.toString())
}
override fun onMessageResentAfterSafetyNumberChange() {
throw UnsupportedOperationException()
}
override fun onCanceled() {
viewModel.cancelSend()
}
companion object {
const val RESULT_SELECTION = "result_selection"
const val RESULT_SELECTION_RECIPIENTS = "result_selection_recipients"
@JvmStatic
fun show(supportFragmentManager: FragmentManager, multiselectForwardFragmentArgs: MultiselectForwardFragmentArgs) {
val fragment = MultiselectForwardFragment()
@@ -185,9 +332,14 @@ class MultiselectForwardFragment : FixedRoundedCornerBottomSheetDialogFragment()
fragment.arguments = Bundle().apply {
putParcelableArrayList(ARG_MULTISHARE_ARGS, ArrayList(multiselectForwardFragmentArgs.multiShareArgs))
putBoolean(ARG_CAN_SEND_TO_NON_PUSH, multiselectForwardFragmentArgs.canSendToNonPush)
putInt(ARG_TITLE, multiselectForwardFragmentArgs.title)
}
fragment.show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
interface Callback {
fun onFinishForwardAction()
}
}

View File

@@ -1,10 +1,12 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.Context
import androidx.annotation.StringRes
import androidx.annotation.WorkerThread
import org.signal.core.util.StreamUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselect
@@ -16,9 +18,17 @@ import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.whispersystems.libsignal.util.guava.Optional
import java.util.function.Consumer
/**
* Arguments for the MultiselectForwardFragment.
*
* @param canSendToNonPush Whether non-push recipients will be displayed
* @param multiShareArgs The items to forward. If this is an empty list, the fragment owner will be sent back a selected list of contacts.
* @param title The title to display at the top of the sheet
*/
class MultiselectForwardFragmentArgs(
val canSendToNonPush: Boolean,
val multiShareArgs: List<MultiShareArgs>
val multiShareArgs: List<MultiShareArgs> = listOf(),
@StringRes val title: Int = R.string.MultiselectForwardFragment__forward_to
) {
companion object {
@@ -42,7 +52,10 @@ class MultiselectForwardFragmentArgs(
@WorkerThread
private fun buildMultiShareArgs(context: Context, conversationMessage: ConversationMessage, selectedParts: Set<MultiselectPart>): MultiShareArgs {
val builder = MultiShareArgs.Builder(setOf()).withMentions(conversationMessage.mentions)
val builder = MultiShareArgs.Builder(setOf())
.withMentions(conversationMessage.mentions)
.withTimestamp(conversationMessage.messageRecord.timestamp)
.withExpiration(conversationMessage.messageRecord.expireStarted + conversationMessage.messageRecord.expiresIn)
if (conversationMessage.multiselectCollection.isTextSelected(selectedParts)) {
val mediaMessage: MmsMessageRecord? = conversationMessage.messageRecord as? MmsMessageRecord
@@ -56,6 +69,9 @@ class MultiselectForwardFragmentArgs(
} else {
builder.withDraftText(conversationMessage.getDisplayBody(context).toString())
}
val linkPreview = mediaMessage?.linkPreviews?.firstOrNull()
builder.withLinkPreview(linkPreview)
}
if (conversationMessage.messageRecord.isMms && conversationMessage.multiselectCollection.isMediaSelected(selectedParts)) {
@@ -86,12 +102,17 @@ class MultiselectForwardFragmentArgs(
} else if (mediaMessage.containsMediaSlide()) {
builder.withMedia(listOf())
builder.withStickerLocator(mediaMessage.slideDeck.stickerSlide?.asAttachment()?.sticker)
if (mediaMessage.slideDeck.stickerSlide != null) {
builder.withDataUri(mediaMessage.slideDeck.stickerSlide?.asAttachment()?.uri)
builder.withStickerLocator(mediaMessage.slideDeck.stickerSlide?.asAttachment()?.sticker)
builder.withDataType(mediaMessage.slideDeck.stickerSlide?.asAttachment()?.contentType)
}
val firstSlide = mediaMessage.slideDeck.slides[0]
val media = firstSlide.asAttachment().toMedia()
if (media != null) {
builder.asBorderless(media.isBorderless)
builder.withMedia(listOf(media))
}
}

View File

@@ -1,14 +1,21 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import android.content.Context
import androidx.core.util.Consumer
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.DatabaseFactory
import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.database.identity.IdentityRecordList
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.MultiShareSender
import org.thoughtcrime.securesms.sharing.ShareContact
import org.thoughtcrime.securesms.sharing.ShareContactAndThread
import org.whispersystems.libsignal.util.guava.Optional
class MultiselectForwardRepository(context: Context) {
@@ -20,6 +27,31 @@ class MultiselectForwardRepository(context: Context) {
val onAllMessagesFailed: () -> Unit
)
fun checkForBadIdentityRecords(shareContacts: List<ShareContact>, consumer: Consumer<List<IdentityRecord>>) {
SignalExecutors.BOUNDED.execute {
val recipients: List<Recipient> = shareContacts.map { Recipient.resolved(it.recipientId.get()) }
val identityRecordList: IdentityRecordList = ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients)
consumer.accept(identityRecordList.untrustedRecords)
}
}
fun canSelectRecipient(recipientId: Optional<RecipientId>): Single<Boolean> {
if (!recipientId.isPresent) {
return Single.just(true)
}
return Single.fromCallable {
val recipient = Recipient.resolved(recipientId.get())
if (recipient.isPushV2Group) {
val record = DatabaseFactory.getGroupDatabase(context).getGroup(recipient.requireGroupId())
!(record.isPresent && record.get().isAnnouncementGroup && !record.get().isAdmin(Recipient.self()))
} else {
true
}
}
}
fun send(
additionalMessage: String,
multiShareArgs: List<MultiShareArgs>,
@@ -38,7 +70,7 @@ class MultiselectForwardRepository(context: Context) {
.toSet()
val mappedArgs: List<MultiShareArgs> = multiShareArgs.map { it.buildUpon(sharedContactsAndThreads).build() }
val results = mappedArgs.map { MultiShareSender.sendSync(it) }
val results = mappedArgs.sortedBy { it.timestamp }.map { MultiShareSender.sendSync(it) }
if (additionalMessage.isNotEmpty()) {
val additional = MultiShareArgs.Builder(sharedContactsAndThreads)

View File

@@ -1,15 +1,22 @@
package org.thoughtcrime.securesms.conversation.mutiselect.forward
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.ShareContact
data class MultiselectForwardState(
val selectedContacts: List<ShareContact> = emptyList(),
val stage: Stage = Stage.SELECTION
val stage: Stage = Stage.Selection
) {
enum class Stage {
SELECTION,
SOME_FAILED,
ALL_FAILED,
SUCCESS
sealed class Stage {
object Selection : Stage()
object FirstConfirmation : Stage()
object LoadingIdentities : Stage()
data class SafetyConfirmation(val identities: List<IdentityRecord>) : Stage()
object SendPending : Stage()
object SomeFailed : Stage()
object AllFailed : Stage()
object Success : Stage()
data class SelectionConfirmed(val recipients: List<RecipientId>) : Stage()
}
}

View File

@@ -4,6 +4,8 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.sharing.ShareContact
@@ -22,8 +24,14 @@ class MultiselectForwardViewModel(
val shareContactMappingModels: LiveData<List<ShareSelectionMappingModel>> = Transformations.map(state) { s -> s.selectedContacts.mapIndexed { i, c -> ShareSelectionMappingModel(c, i == 0) } }
fun addSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) }
fun addSelectedContact(recipientId: Optional<RecipientId>, number: String?): Single<Boolean> {
return repository
.canSelectRecipient(recipientId)
.doOnSuccess { allowed ->
if (allowed) {
store.update { it.copy(selectedContacts = it.selectedContacts + ShareContact(recipientId, number)) }
}
}
}
fun removeSelectedContact(recipientId: Optional<RecipientId>, number: String?) {
@@ -31,16 +39,55 @@ class MultiselectForwardViewModel(
}
fun send(additionalMessage: String) {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
shareContacts = store.state.selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.SUCCESS) } },
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.ALL_FAILED) } },
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SOME_FAILED) } }
if (SignalStore.tooltips().showMultiForwardDialog()) {
SignalStore.tooltips().markMultiForwardDialogSeen()
store.update { it.copy(stage = MultiselectForwardState.Stage.FirstConfirmation) }
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.LoadingIdentities) }
repository.checkForBadIdentityRecords(store.state.selectedContacts) { identityRecords ->
if (identityRecords.isEmpty()) {
performSend(additionalMessage)
} else {
store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) }
}
}
}
}
fun confirmFirstSend(additionalMessage: String) {
send(additionalMessage)
}
fun confirmSafetySend(additionalMessage: String) {
send(additionalMessage)
}
fun cancelSend() {
store.update { it.copy(stage = MultiselectForwardState.Stage.Selection) }
}
private fun performSend(additionalMessage: String) {
store.update { it.copy(stage = MultiselectForwardState.Stage.SendPending) }
if (records.isEmpty()) {
store.update { state ->
state.copy(
stage = MultiselectForwardState.Stage.SelectionConfirmed(
state.selectedContacts.filter { it.recipientId.isPresent }.map { it.recipientId.get() }.distinct()
)
)
}
} else {
repository.send(
additionalMessage = additionalMessage,
multiShareArgs = records,
shareContacts = store.state.selectedContacts,
MultiselectForwardRepository.MultiselectForwardResultHandlers(
onAllMessageSentSuccessfully = { store.update { it.copy(stage = MultiselectForwardState.Stage.Success) } },
onAllMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.AllFailed) } },
onSomeMessagesFailed = { store.update { it.copy(stage = MultiselectForwardState.Stage.SomeFailed) } }
)
)
)
}
}
class Factory(

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.conversation.ui.error;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
/**

View File

@@ -16,6 +16,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
@@ -80,6 +81,6 @@ final class SafetyNumberChangeAdapter extends ListAdapter<ChangedRecipient, Safe
}
interface Callbacks {
void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord);
void onViewIdentityRecord(@NonNull IdentityRecord identityRecord);
}
}

View File

@@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -62,9 +63,9 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG);
}
public static void show(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityDatabase.IdentityRecord> identityRecords) {
public static void show(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityRecord> identityRecords) {
List<String> ids = Stream.of(identityRecords)
.filterNot(IdentityDatabase.IdentityRecord::isFirstUse)
.filterNot(IdentityRecord::isFirstUse)
.map(record -> record.getRecipientId().serialize())
.distinct()
.toList();
@@ -102,9 +103,9 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG);
}
public static void showForGroupCall(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityDatabase.IdentityRecord> identityRecords) {
public static void showForGroupCall(@NonNull FragmentManager fragmentManager, @NonNull List<IdentityRecord> identityRecords) {
List<String> ids = Stream.of(identityRecords)
.filterNot(IdentityDatabase.IdentityRecord::isFirstUse)
.filterNot(IdentityRecord::isFirstUse)
.map(record -> record.getRecipientId().serialize())
.distinct()
.toList();
@@ -214,7 +215,12 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
if (activity instanceof Callback && !skipCallbacks) {
callback = (Callback) activity;
} else {
callback = null;
Fragment parent = getParentFragment();
if (parent instanceof Callback && !skipCallbacks) {
callback = (Callback) parent;
} else {
callback = null;
}
}
LiveData<TrustAndVerifyResult> trustOrVerifyResultLiveData = viewModel.trustOrVerifyChangedRecipients();
@@ -244,11 +250,13 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa
private void handleCancel(@NonNull DialogInterface dialogInterface, int which) {
if (getActivity() instanceof Callback) {
((Callback) getActivity()).onCanceled();
} else if (getParentFragment() instanceof Callback) {
((Callback) getParentFragment()).onCanceled();
}
}
@Override
public void onViewIdentityRecord(@NonNull IdentityDatabase.IdentityRecord identityRecord) {
public void onViewIdentityRecord(@NonNull IdentityRecord identityRecord) {
startActivity(VerifyIdentityActivity.newIntent(requireContext(), identityRecord));
}

View File

@@ -17,11 +17,12 @@ import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.database.MessageDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.sms.MessageSender;
@@ -67,7 +68,7 @@ final class SafetyNumberChangeRepository {
List<Recipient> recipients = Stream.of(recipientIds).map(Recipient::resolved).toList();
List<ChangedRecipient> changedRecipients = Stream.of(DatabaseFactory.getIdentityDatabase(context).getIdentities(recipients).getIdentityRecords())
List<ChangedRecipient> changedRecipients = Stream.of(ApplicationDependencies.getIdentityStore().getIdentityRecords(recipients).getIdentityRecords())
.map(record -> new ChangedRecipient(Recipient.resolved(record.getRecipientId()), record))
.toList();
@@ -95,7 +96,7 @@ final class SafetyNumberChangeRepository {
@WorkerThread
private TrustAndVerifyResult trustOrVerifyChangedRecipientsInternal(@NonNull List<ChangedRecipient> changedRecipients) {
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
TextSecureIdentityKeyStore identityStore = ApplicationDependencies.getIdentityStore();
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (ChangedRecipient changedRecipient : changedRecipients) {
@@ -103,12 +104,12 @@ final class SafetyNumberChangeRepository {
if (changedRecipient.isUnverified()) {
Log.d(TAG, "Setting " + identityRecord.getRecipientId() + " as verified");
identityDatabase.setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
ApplicationDependencies.getIdentityStore().setVerified(identityRecord.getRecipientId(),
identityRecord.getIdentityKey(),
IdentityDatabase.VerifiedStatus.DEFAULT);
} else {
Log.d(TAG, "Setting " + identityRecord.getRecipientId() + " as approved");
identityDatabase.setApproval(identityRecord.getRecipientId(), true);
identityStore.setApproval(identityRecord.getRecipientId(), true);
}
}
}
@@ -125,15 +126,16 @@ final class SafetyNumberChangeRepository {
try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) {
for (ChangedRecipient changedRecipient : changedRecipients) {
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(context);
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(changedRecipient.getRecipient().requireServiceId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
Log.d(TAG, "Saving identity for: " + changedRecipient.getRecipient().getId() + " " + changedRecipient.getIdentityRecord().getIdentityKey().hashCode());
TextSecureIdentityKeyStore.SaveResult result = identityKeyStore.saveIdentity(mismatchAddress, changedRecipient.getIdentityRecord().getIdentityKey(), true);
TextSecureIdentityKeyStore.SaveResult result = ApplicationDependencies.getIdentityStore().saveIdentity(mismatchAddress, changedRecipient.getIdentityRecord().getIdentityKey(), true);
Log.d(TAG, "Saving identity result: " + result);
if (result == TextSecureIdentityKeyStore.SaveResult.NO_CHANGE) {
Log.i(TAG, "Archiving sessions explicitly as they appear to be out of sync.");
SessionUtil.archiveSession(context, changedRecipient.getRecipient().getId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
SessionUtil.archiveSiblingSessions(context, mismatchAddress);
SessionUtil.archiveSession(changedRecipient.getRecipient().getId(), SignalServiceAddress.DEFAULT_DEVICE_ID);
SessionUtil.archiveSiblingSessions(mismatchAddress);
DatabaseFactory.getSenderKeySharedDatabase(context).deleteAllFor(changedRecipient.getRecipient().getId());
}
}

View File

@@ -101,7 +101,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
v.setLayoutParams(new FrameLayout.LayoutParams(1, ViewUtil.dpToPx(100)));
return new PlaceholderViewHolder(v);
} else if (viewType == TYPE_HEADER) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.conversation_list_item_header, parent, false);
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.dsl_section_header, parent, false);
return new HeaderViewHolder(v);
} else {
throw new IllegalStateException("Unknown type! " + viewType);
@@ -297,7 +297,7 @@ class ConversationListAdapter extends ListAdapter<Conversation, RecyclerView.Vie
public HeaderViewHolder(@NonNull View itemView) {
super(itemView);
headerText = (TextView) itemView;
headerText = itemView.findViewById(R.id.section_header);
}
}

View File

@@ -6,6 +6,7 @@ import android.database.MatrixCursor;
import android.database.MergeCursor;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.signal.core.util.logging.Log;
@@ -23,7 +24,7 @@ import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
abstract class ConversationListDataSource implements PagedDataSource<Conversation> {
abstract class ConversationListDataSource implements PagedDataSource<Long, Conversation> {
private static final String TAG = Log.tag(ConversationListDataSource.class);
@@ -73,6 +74,16 @@ abstract class ConversationListDataSource implements PagedDataSource<Conversatio
return conversations;
}
@Override
public @Nullable Conversation load(Long threadId) {
throw new UnsupportedOperationException("Not implemented!");
}
@Override
public @NonNull Long getKey(@NonNull Conversation conversation) {
return conversation.getThreadRecord().getThreadId();
}
protected abstract int getTotalCount();
protected abstract Cursor getCursor(long offset, long limit);

View File

@@ -106,7 +106,7 @@ import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController;
import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
@@ -269,7 +269,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
.ifNecessary()
.withRationaleDialog(getString(R.string.ConversationActivity_to_capture_photos_and_video_allow_signal_access_to_the_camera), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video))
.onAllGranted(() -> startActivity(MediaSendActivity.buildCameraFirstIntent(requireActivity())))
.onAllGranted(() -> startActivity(MediaSelectionActivity.camera(requireContext())))
.onAnyDenied(() -> Toast.makeText(requireContext(), R.string.ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video, Toast.LENGTH_LONG).show())
.execute();
});

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