Compare commits

..

156 Commits

Author SHA1 Message Date
Greyson Parrelli
382ac7ba0d Bump version to 4.62.1 2020-06-06 20:28:18 -04:00
Greyson Parrelli
a46f47f352 Updated language translations. 2020-06-06 20:27:48 -04:00
Greyson Parrelli
e984d8a42c Change Gif -> GIF. 2020-06-06 20:27:22 -04:00
Greyson Parrelli
554bad6b8d Improve DB access in group sends. 2020-06-06 20:25:02 -04:00
Jim Gustafson
ed13c97ad7 Handle legacy hangup properly. 2020-06-06 20:25:02 -04:00
Greyson Parrelli
d33873d59a Fix possible crash with null thread body. 2020-06-06 20:25:02 -04:00
Greyson Parrelli
1234899ea1 Add support for non-blocking media sends. 2020-06-06 20:25:02 -04:00
Cody Henthorne
13027dc44b Fix leaking MessageDetailsActivity via list items. 2020-06-06 20:25:02 -04:00
Cody Henthorne
5b4d74b7fe Move group resolution for conversations to background LiveData. 2020-06-06 20:25:02 -04:00
Alan Evans
18c7bc2b5b Prevent edit of a group post leave. 2020-06-06 20:25:02 -04:00
Alan Evans
bbbee0f372 Fix group create arrow for RTL. 2020-06-06 20:25:02 -04:00
Alex Hart
cf9d090154 Start Paging @ Unread count instead of -1. 2020-06-06 20:25:02 -04:00
Alan Evans
718471917f Separate text only message layouts. 2020-06-06 20:25:02 -04:00
Greyson Parrelli
bb97407cde Bump version to 4.62.0 2020-06-05 22:04:18 -04:00
Greyson Parrelli
92ce678e29 Updated language translations. 2020-06-05 22:04:16 -04:00
Cody Henthorne
e100aea2c7 Preserve scroll position in Message Details on update. 2020-06-05 21:46:04 -04:00
Greyson Parrelli
fea3b6cb4a Don't show 'conversation settings' for groups. 2020-06-05 21:46:04 -04:00
Cody Henthorne
afbc132faa Fix conversation item and data source memory leaks. 2020-06-05 21:46:04 -04:00
Alan Evans
b27198286d MMS proof new group UI. 2020-06-05 21:46:04 -04:00
Greyson Parrelli
ac93d81032 Remove pins4all feature flag. 2020-06-05 21:46:04 -04:00
Alan Evans
9981e5ca76 Enable new group UI. 2020-06-05 20:19:03 -03:00
Cody Henthorne
7dd3efeb53 Remove listeners when detaching conversation item views. 2020-06-05 19:29:55 -03:00
Greyson Parrelli
d38d702adf Parallelize group sends. 2020-06-05 18:10:50 -04:00
Alex Hart
04a000a8a8 Always display labels in contact search. 2020-06-05 15:16:55 -03:00
Fumiaki Yoshimatsu
3bbf0741ee Use localized string for Phone number.
Fixes #9626
2020-06-05 15:01:20 -03:00
Fumiaki Yoshimatsu
e9a336100b Display backup date in users locale on restore.
Fixes #9693
2020-06-05 15:01:20 -03:00
Cody Henthorne
fb600e9829 Update SMS/MMS as sending when retrying failed send.
This was only impacting SMS/MMS as Push already reset the status.
2020-06-05 13:46:25 -04:00
Alex Hart
4a455ff958 Implement new Add Members UI. 2020-06-05 13:44:02 -03:00
Cody Henthorne
707e238e5c Make borderless button style responsive to theme. 2020-06-05 12:01:00 -04:00
Alan Evans
90f22a4b66 Include face position and projection matrix into elements matrix. 2020-06-04 19:49:22 -03:00
Alex Hart
b4f134adf7 Add more descriptive messages for media notifications and chat previews. 2020-06-04 13:13:42 -03:00
Greyson Parrelli
1e00fc6149 Bump version to 4.61.6 2020-06-04 10:21:23 -04:00
Greyson Parrelli
f52133a69c Updated language translations. 2020-06-04 10:21:23 -04:00
Alan Evans
91b142e0d9 Fix waveform array out of bounds. 2020-06-04 10:21:10 -04:00
Greyson Parrelli
26a9dd98c1 Bump version to 4.61.5 2020-06-03 19:01:03 -04:00
Greyson Parrelli
99e38e1d23 Updated language translations. 2020-06-03 18:58:34 -04:00
Greyson Parrelli
a2d8a25fd9 Blur UI tweaks. 2020-06-03 18:51:38 -04:00
Alan Evans
d86d625bcc Smoother blur rendering. 2020-06-03 19:47:51 -03:00
Greyson Parrelli
18e3fb6609 Fix string format. 2020-06-03 17:19:32 -04:00
Greyson Parrelli
da33ba0ed5 Update blur UI. 2020-06-03 17:12:47 -04:00
Greyson Parrelli
66f021d01a Fix issue where rail wasn't showing in some situations. 2020-06-03 17:12:47 -04:00
Greyson Parrelli
40231ea45f Fix issue with view-once toggle and face blurring. 2020-06-03 17:12:42 -04:00
Alex Hart
cd80a47c04 Made edit profile save button move with the keyboard. 2020-06-03 17:12:27 -04:00
Alan Evans
1033bd7bda Blur faces rotation and crop and zoom support. 2020-06-03 14:02:24 -03:00
Greyson Parrelli
b4f60f3acb Bump version to 4.61.4 2020-06-03 06:40:09 -04:00
Greyson Parrelli
bed3b571cc Updated language translations. 2020-06-03 06:39:34 -04:00
Greyson Parrelli
c8dd4e5254 Added support for blurring faces.
Co-authored-by: Alan Evans <alan@signal.org>
2020-06-03 06:39:20 -04:00
Alan Evans
514048171b Add Image Editor support for blur mask layer. 2020-06-03 03:33:06 -03:00
Greyson Parrelli
32e9901592 Bump version to 4.61.3 2020-06-02 19:22:22 -04:00
Greyson Parrelli
d83f86a469 Revert "Make notifications and chat previews for media messages more descriptive."
This reverts commit a3f9737e63.
2020-06-02 19:19:30 -04:00
Greyson Parrelli
403d53586c Bump version to 4.61.2 2020-06-02 17:40:56 -04:00
Greyson Parrelli
6acae58694 Updated language translations. 2020-06-02 17:33:41 -04:00
Alex Hart
a3f9737e63 Make notifications and chat previews for media messages more descriptive. 2020-06-02 17:34:50 -03:00
Cody Henthorne
263af7c139 Add registration lock status to support email. 2020-06-02 16:14:19 -04:00
Alex Hart
7f2439f1e9 Fix contact selection behavior when searching and clear search on selection. 2020-06-02 16:27:04 -03:00
Alex Hart
ae87d23003 Always use the new group settings screen if the flag is enabled. 2020-06-02 16:09:48 -03:00
Alex Hart
3192cc0aac Add outlined view-once close icon. 2020-06-02 16:05:16 -03:00
Alex Hart
6102e9aa72 Apply better coordinatorlayout animation and RTL support. 2020-06-02 15:02:35 -03:00
Alan Evans
f4a152b0fe Fetch own profile after GV2 feature flag is enabled, improve GV2 capability check. 2020-06-02 11:48:40 -03:00
Greyson Parrelli
2b11bca7dc Guard against possible invalid conversation data loads. 2020-06-02 10:20:55 -04:00
Artem Varaksa
07d19f38e3 Fix typos in logging for remote delete. 2020-06-02 10:22:29 -03:00
Greyson Parrelli
cd228c439e Be more explicit with the ID we use for account updates. 2020-06-02 09:03:54 -04:00
Alan Evans
7a859c8961 For smaller width devices, use original 210dp for audio messages. 2020-06-02 09:32:59 -03:00
Alan Evans
543f38c75d Fix Wave form IOException thread issue. 2020-06-02 07:38:15 -03:00
Greyson Parrelli
f7b150f2d2 Bump version to 4.61.1 2020-06-01 17:43:05 -04:00
Greyson Parrelli
11328f643f Updated language translations. 2020-06-01 17:43:05 -04:00
Greyson Parrelli
f270a6b8c4 Fix potential crash by removing an unnecessary column.
The column I removed is already in the recipient half of the projection.
Having two representations of the groupId made reading the groupId out
of the cursor non-deterministic, and when compounded with another bug,
could cause a crash if one of them was null.
2020-06-01 17:43:05 -04:00
Alan Evans
3fec23fd36 Show remaining time on wave form view and cache wave form in database. 2020-06-01 17:43:05 -04:00
Alex Hart
e01838e996 Fix text size for pending members. 2020-06-01 17:43:05 -04:00
Greyson Parrelli
f70e41e7cd Don't allow account record updates to delete our profile key. 2020-06-01 17:43:05 -04:00
Greyson Parrelli
c4ec0c9897 Handle devices disallowing start of FcmFetchService.
Some devices are overzealous with battery management and disallow
starting services even when they're in response to a high-priority FCM
message (which should be allowed). So in these situations, we just
fall back to what we were doing before.
2020-06-01 17:43:05 -04:00
Greyson Parrelli
989b071a6d Ignore contacts that don't have a phone number. 2020-06-01 17:43:05 -04:00
Greyson Parrelli
c39751f9db Add info about play services to the debug log. 2020-06-01 17:43:05 -04:00
jimio-signal
dbf74a2234 Update copyright in README.md 2020-05-31 10:39:19 -07:00
Greyson Parrelli
837230d72d Bump version to 4.61.0 2020-05-29 19:18:55 -04:00
Greyson Parrelli
f544ec4126 Updated language translations. 2020-05-29 19:18:02 -04:00
Greyson Parrelli
79dbf85c1e Improve local encrypted PIN storage. 2020-05-29 19:15:56 -04:00
Greyson Parrelli
61fe6cc961 Enable the ability to react with any emoji. 2020-05-29 19:14:37 -04:00
Greyson Parrelli
70c88b68e2 Store recent reactions separately from keyboard emoji. 2020-05-29 19:14:37 -04:00
Greyson Parrelli
d70c33d20f Add support for mark as unread. 2020-05-29 19:14:37 -04:00
Greyson Parrelli
6b2e000e61 Prevent waiting for old queues in our retrieval strategies. 2020-05-29 19:14:37 -04:00
Alan Evans
b9f11dafff New internal testing flag and V1 group creation button. All menus create GV1. 2020-05-29 19:14:37 -04:00
Alan Evans
9b32eaeb8a Do not log URLs. 2020-05-29 19:14:37 -04:00
Alan Evans
a99c0d438e Rename GV2 "version" to "revision". 2020-05-29 19:14:37 -04:00
Alex Hart
c634c24afb Utilize Wrapper instead of dynamic theme. 2020-05-29 19:14:37 -04:00
Alex Hart
2ddd1437cf Utilize exclusive AudioFocus. 2020-05-29 19:14:37 -04:00
Alan Evans
9da309ca48 Enforce a local GV2 capacity limit driven by a feature flag. 2020-05-29 19:14:37 -04:00
henry
cfcd451db7 Fix crash on unlink device when offline. 2020-05-29 09:51:21 -04:00
Alex Hart
5ab72fd1a9 Ask for permission before launching avatar sheet. 2020-05-29 09:51:21 -04:00
Alan Evans
daace9bd1a Audio wave forms on voice notes. 2020-05-29 09:51:21 -04:00
Alan Evans
69adcd1d69 Tap avatar in chat preferences or group management to see full screen. 2020-05-29 09:51:21 -04:00
Alex Hart
0711a22188 Add overflow toast and fix edit menu option. 2020-05-29 09:51:21 -04:00
Greyson Parrelli
3a06412cd8 Throttle notifications when doing the intial message fetch. 2020-05-29 09:51:21 -04:00
Alan Evans
51c82702e2 Remove expectation of ActionBar in DeviceProvisioningActivity.
Fixes #9661
2020-05-29 09:51:21 -04:00
Greyson Parrelli
1b01196ec6 Refactor ThreadRecord. 2020-05-29 09:51:21 -04:00
Greyson Parrelli
1cd6b58ece Don't enqueue duplicate PushDecryptMessageJobs. 2020-05-29 09:51:21 -04:00
Greyson Parrelli
ea8e13b1c8 Create a WebsocketDrainedConstraint. 2020-05-29 09:51:21 -04:00
Greyson Parrelli
f392229393 Extract MessageNotifier interface. 2020-05-29 09:51:21 -04:00
Greyson Parrelli
a299bafe89 Create a new system for fetching the intial batch of messages. 2020-05-29 09:51:21 -04:00
Alex Hart
d2bf539504 Clear sticky WebRtcViewModel events when initiating a new call. 2020-05-29 09:51:21 -04:00
Alex Hart
903c3989b9 Fix chip jank and other groups v2 ux issues. 2020-05-29 09:51:21 -04:00
Alan Evans
00996f0d7a Rename back to build.gradle 2020-05-29 09:51:21 -04:00
Alan Evans
4aded3a436 Close keyboard on contact list scroll. 2020-05-29 09:51:20 -04:00
Alan Evans
9acdc37729 Alphabetical member order. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
d4cdcbe54f Improve logging around group sends. 2020-05-29 09:51:20 -04:00
Alex Hart
6fa2a0f411 Polish UX for groups v2 management. 2020-05-29 09:51:20 -04:00
Alex Hart
558a8e4a14 Add polish to groups v2 creation flow. 2020-05-29 09:51:20 -04:00
Alan Evans
8947b82034 Make GV2 feature flags remote capable. 2020-05-29 09:51:20 -04:00
Alan Evans
56551025e9 Detect if group v2 is active from membership. 2020-05-29 09:51:20 -04:00
Alan Evans
befb4939d5 Restore groups from storage service. 2020-05-29 09:51:20 -04:00
Alan Evans
289f7aba63 Add versioned profiles feature flag. 2020-05-29 09:51:20 -04:00
Alan Evans
28bd245b96 While testing GV2 without UUID, fail jobs that hit UuidRecipientError. 2020-05-29 09:51:20 -04:00
Alan Evans
c5e7300df2 Fix matches logic in contact selection. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
fe25d941bb Prevent FCM bottlenecking. 2020-05-29 09:51:20 -04:00
Alan Evans
4cda267f3b Show pending count and allow view of zero pending screen. 2020-05-29 09:51:20 -04:00
Alex Hart
82ba7e2b8b Display "You" at end of members list in ConversationTitleView. 2020-05-29 09:51:20 -04:00
Alex Hart
41ebaf3938 Clean up Overflow menu for GV2 groups. 2020-05-29 09:51:20 -04:00
Alex Hart
090c400037 Collapse title into toolbar on scroll in ManageGroupFragment. 2020-05-29 09:51:20 -04:00
Alan Evans
12b1232ac0 Fix groups v2 patch response handler. 2020-05-29 09:51:20 -04:00
Alex Hart
204a84c522 Apply proper spacing to RecipientBottomSheetDialogFragment. 2020-05-29 09:51:20 -04:00
Alan Evans
526afd539b Fix avatar tap in conversation multi-select mode. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
d708984abd Require users be a system contact or whitelisted to appear in the contact list. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
9d39db6428 Add additional account restore logging, prevent double avatar fetch. 2020-05-29 09:51:20 -04:00
Alan Evans
67a8ec0d39 Only admin can cancel any invite. 2020-05-29 09:51:20 -04:00
Alan Evans
297a7d0ef8 Handle absent change during invite. 2020-05-29 09:51:20 -04:00
Bastian Köcher
4712833853 Always convert HEIC images to JPEG.
This pr changes the behavior of sending HEIC images to always convert
them to JPEG. This conversion is required to support image inline
viewing accross different devices and operating systems. This follows
the same strategy as on IOS: https://github.com/signalapp/Signal-iOS/pull/2511

Fixes: https://github.com/signalapp/Signal-iOS/issues/4374 & https://github.com/signalapp/Signal-Android/issues/9395
2020-05-29 09:51:20 -04:00
Alan Evans
11d17f7496 GV2 storage service syncing. 2020-05-29 09:51:20 -04:00
Alan Evans
36df3f234f Enable the Zk group library. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
098b298646 Add a network constraint to RemoteConfigRefreshJob. 2020-05-29 09:51:20 -04:00
Alan Evans
2f9320989a Server signed group v2 changes sent and received P2P. 2020-05-29 09:51:20 -04:00
Alan Evans
ec8d5defd4 Protect against unknown GV2 UUIDs. 2020-05-29 09:51:20 -04:00
Greyson Parrelli
981676c7f8 Bump version to 4.60.9 2020-05-29 09:40:26 -04:00
Greyson Parrelli
7c5ae57784 Updated language translations. 2020-05-29 09:38:57 -04:00
Alex Hart
fc7be87468 Downgrade AudioManagerCompat errors to warnings. 2020-05-29 10:31:36 -03:00
Greyson Parrelli
e55d8007fc Bump version to 4.60.8 2020-05-28 18:34:06 -04:00
Greyson Parrelli
43b7aa2d52 Updated language translations. 2020-05-28 18:33:46 -04:00
Alex Hart
cd1bad0718 Fix bluetooth behavior. 2020-05-28 17:36:40 -03:00
Greyson Parrelli
6b47618351 Bump version to 4.60.7 2020-05-26 18:27:05 -04:00
Greyson Parrelli
b6d384120d Updated language translations. 2020-05-26 18:26:39 -04:00
Greyson Parrelli
1268b26c1f Auto-dismiss PIN reminder dialog as you type. 2020-05-26 18:13:19 -04:00
Greyson Parrelli
f1233bfddc Bump version to 4.60.6 2020-05-25 14:57:59 -04:00
Greyson Parrelli
1aa3e6afea Updated language translations. 2020-05-25 14:57:59 -04:00
Greyson Parrelli
ce21eb241a Fix potential crash with data size in ConversationDataSource. 2020-05-25 14:57:59 -04:00
Greyson Parrelli
f96fb72eb1 Don't show PIN reminders if you're not registered.
Fixes #9657
2020-05-25 13:14:38 -04:00
Greyson Parrelli
207c467c6b Don't insert identity verification message for the initial restore. 2020-05-24 13:00:16 -04:00
Alex Hart
9d1d9e33ed Bumped version to 4.60.5 2020-05-21 20:03:31 -03:00
Alex Hart
e4a76c0690 Updated language translations. 2020-05-21 20:03:31 -03:00
Alex Hart
124c3e25e9 Implement layout changes to new call screen UX. 2020-05-21 20:03:31 -03:00
Greyson Parrelli
5cb1201903 Add the ability to disable PIN reminders. 2020-05-21 19:56:30 -03:00
Greyson Parrelli
bb6ca80d5a Don't create identity change methods for brand new contacts. 2020-05-21 19:56:30 -03:00
Greyson Parrelli
dc7c54a1f8 Ensure we upload the profile after a PIN restore. 2020-05-21 19:56:30 -03:00
Greyson Parrelli
23401440bf Prevent insertion of UUID-only contacts at the database level. 2020-05-21 19:56:30 -03:00
Greyson Parrelli
f8f959e05a Make rate limit message more generic. 2020-05-20 14:22:33 -04:00
Alex Hart
edbd4d2d03 Properly set profile key update flag. 2020-05-20 12:15:54 -03:00
Greyson Parrelli
a0b4065be3 Fix potention OOB error when pulse-highlighting a message.
This basically happened if you used full-text search to search for the
latest message in a conversation, but when you navigated there, it
*also* had a header set (like a typing indicator or unknownSenderView).
2020-05-19 17:09:25 -04:00
Greyson Parrelli
1b2f964f32 Fix possible crash around loading initial conversation pages. 2020-05-19 16:20:20 -04:00
421 changed files with 14861 additions and 4400 deletions

View File

@@ -59,9 +59,7 @@ The form and manner of this distribution makes it eligible for export under the
## License
Copyright 2011 Whisper Systems
Copyright 2013-2020 Open Whisper Systems
Copyright 2013-2020 Signal
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html

View File

@@ -80,8 +80,8 @@ protobuf {
}
}
def canonicalVersionCode = 635
def canonicalVersionName = "4.60.4"
def canonicalVersionCode = 649
def canonicalVersionName = "4.62.1"
def postFixSize = 10
def abiPostFix = ['universal' : 0,
@@ -126,7 +126,7 @@ android {
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\""
buildConfigField "String", "KBS_MRENCLAVE", "\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\""
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
@@ -161,7 +161,6 @@ android {
exclude 'META-INF/LICENSE'
exclude 'META-INF/NOTICE'
exclude 'META-INF/proguard/androidx-annotations.pro'
exclude 'lib/*/libzkgroup.so' // TODO: GV2 Remove line to include .so when used
}
buildTypes {
@@ -201,7 +200,7 @@ android {
buildConfigField "String", "CDS_MRENCLAVE", "\"ba4ebb438bc07713819ee6c98d94037747006d7df63fc9e44d2d6f1fec962a79\""
buildConfigField "String", "KBS_ENCLAVE_NAME", "\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\""
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
}
flipper {
initWith debug
@@ -282,9 +281,10 @@ dependencies {
implementation "androidx.autofill:autofill:1.0.0"
implementation "androidx.paging:paging-common:2.1.2"
implementation "androidx.paging:paging-runtime:2.1.2"
implementation 'com.google.firebase:firebase-ml-vision:24.0.3'
implementation 'com.google.firebase:firebase-ml-vision-face-model:20.0.1'
implementation('com.google.firebase:firebase-messaging:17.3.4') {
implementation ('com.google.firebase:firebase-messaging:20.2.0') {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'

View File

@@ -258,6 +258,7 @@
android:theme="@style/TextSecure.LightNoActionBar" />
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".DatabaseMigrationActivity"
@@ -346,12 +347,17 @@
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".MediaPreviewActivity"
<activity android:name=".MediaPreviewActivity"
android:label="@string/AndroidManifest__media_preview"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".AvatarPreviewActivity"
android:label="@string/AndroidManifest__media_preview"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".mediaoverview.MediaOverviewActivity"
android:theme="@style/TextSecure.LightNoActionBar"
android:windowSoftInputMode="stateHidden"
@@ -422,7 +428,7 @@
<activity android:name=".profiles.edit.EditProfileActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="adjustResize" />
android:windowSoftInputMode="stateVisible|adjustResize" />
<activity android:name=".lock.v2.CreateKbsPinActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
@@ -486,13 +492,16 @@
<activity android:name=".groups.ui.creategroup.CreateGroupActivity"
android:theme="@style/TextSecure.LightNoActionBar" />
<activity android:name=".groups.ui.addmembers.AddMembersActivity"
android:theme="@style/TextSecure.LightNoActionBar" />
<activity android:name=".groups.ui.creategroup.details.AddGroupDetailsActivity"
android:theme="@style/TextSecure.LightNoActionBar" />
<service android:enabled="true" android:name="org.thoughtcrime.securesms.service.WebRtcCallService"/>
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
<service android:enabled="true" android:name=".service.IncomingMessageObserver$ForegroundService"/>
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
<service android:name=".service.QuickResponseService"
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE"
@@ -531,7 +540,9 @@
<service android:name=".service.GenericForegroundService"/>
<service android:name=".gcm.FcmService">
<service android:name=".gcm.FcmFetchService" />
<service android:name=".gcm.FcmReceiveService">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>

View File

@@ -50,8 +50,8 @@ import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.logging.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.messages.InitialMessageRetriever;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
@@ -60,7 +60,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
@@ -153,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
KeyCachingService.onAppForegrounded(this);
ApplicationDependencies.getFrameRateTracker().begin();
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
catchUpOnMessages();
}
@Override
@@ -160,7 +161,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
isAppVisible = false;
Log.i(TAG, "App is no longer visible.");
KeyCachingService.onAppBackgrounded(this);
MessageNotifier.setVisibleThread(-1);
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
ApplicationDependencies.getFrameRateTracker().end();
}
@@ -377,6 +378,36 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
});
}
private void catchUpOnMessages() {
InitialMessageRetriever retriever = ApplicationDependencies.getInitialMessageRetriever();
if (retriever.isCaughtUp()) {
return;
}
SignalExecutors.UNBOUNDED.execute(() -> {
long startTime = System.currentTimeMillis();
switch (retriever.begin(TimeUnit.SECONDS.toMillis(60))) {
case SUCCESS:
Log.i(TAG, "Successfully caught up on messages. " + (System.currentTimeMillis() - startTime) + " ms");
break;
case FAILURE_TIMEOUT:
Log.w(TAG, "Did not finish catching up due to a timeout. " + (System.currentTimeMillis() - startTime) + " ms");
break;
case FAILURE_ERROR:
Log.w(TAG, "Did not finish catching up due to an error. " + (System.currentTimeMillis() - startTime) + " ms");
break;
case SKIPPED_ALREADY_CAUGHT_UP:
Log.i(TAG, "Already caught up. " + (System.currentTimeMillis() - startTime) + " ms");
break;
case SKIPPED_ALREADY_RUNNING:
Log.i(TAG, "Already in the process of catching up. " + (System.currentTimeMillis() - startTime) + " ms");
break;
}
});
}
@Override
protected void attachBaseContext(Context base) {
super.attachBaseContext(DynamicLanguageContextWrapper.updateContext(base, TextSecurePreferences.getLanguage(base)));

View File

@@ -0,0 +1,152 @@
package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityOptionsCompat;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
/**
* Activity for displaying avatars full screen.
*/
public final class AvatarPreviewActivity extends PassphraseRequiredActionBarActivity {
private static final String TAG = Log.tag(AvatarPreviewActivity.class);
private static final String RECIPIENT_ID_EXTRA = "recipient_id";
public static @NonNull Intent intentFromRecipientId(@NonNull Context context,
@NonNull RecipientId recipientId)
{
Intent intent = new Intent(context, AvatarPreviewActivity.class);
intent.putExtra(RECIPIENT_ID_EXTRA, recipientId.serialize());
return intent;
}
public static Bundle createTransitionBundle(@NonNull Activity activity, @NonNull View from) {
return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
super.onCreate(savedInstanceState, ready);
setTheme(R.style.TextSecure_MediaPreview);
setContentView(R.layout.contact_photo_preview_activity);
Toolbar toolbar = findViewById(R.id.toolbar);
ImageView avatar = findViewById(R.id.avatar);
setSupportActionBar(toolbar);
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN);
showSystemUI();
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
Context context = getApplicationContext();
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
Recipient.live(recipientId).observe(this, recipient -> {
ContactPhoto contactPhoto = recipient.isLocalNumber() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar())
: recipient.getContactPhoto();
FallbackContactPhoto fallbackPhoto = recipient.isLocalNumber() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
: recipient.getFallbackContactPhoto();
GlideApp.with(this).load(contactPhoto)
.fallback(fallbackPhoto.asCallCard(this))
.error(fallbackPhoto.asCallCard(this))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.addListener(new RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
Log.w(TAG, "Unable to load avatar, or avatar removed, closing");
finish();
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
return false;
}
})
.into(avatar);
toolbar.setTitle(recipient.toShortString(context));
});
avatar.setOnClickListener(v -> toggleUiVisibility());
showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
}
private static void showAndHideWithSystemUI(@NonNull Window window, @NonNull View... views) {
window.getDecorView().setOnSystemUiVisibilityChangeListener(visibility -> {
boolean hide = (visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0;
for (View view : views) {
view.animate()
.alpha(hide ? 0 : 1)
.start();
}
});
}
private void toggleUiVisibility() {
int systemUiVisibility = getWindow().getDecorView().getSystemUiVisibility();
if ((systemUiVisibility & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0) {
showSystemUI();
} else {
hideSystemUI();
}
}
private void hideSystemUI() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_FULLSCREEN );
}
private void showSystemUI() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN );
}
@Override
public boolean onSupportNavigateUp() {
onBackPressed();
return true;
}
}

View File

@@ -10,8 +10,8 @@ import java.util.Set;
public interface BindableConversationListItem extends Unbindable {
public void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads, boolean batchMode);
void bind(@NonNull ThreadRecord thread,
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
@NonNull Set<Long> typingThreads,
@NonNull Set<Long> selectedThreads, boolean batchMode);
}

View File

@@ -3,8 +3,16 @@ package org.thoughtcrime.securesms;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.view.ContextThemeWrapper;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ThemeUtil;
public class ClearProfileAvatarActivity extends Activity {
private static final String ARG_TITLE = "arg_title";
@@ -25,17 +33,17 @@ public class ClearProfileAvatarActivity extends Activity {
int titleId = getIntent().getIntExtra(ARG_TITLE, R.string.ClearProfileActivity_remove_profile_photo);
new AlertDialog.Builder(this)
.setTitle(titleId)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
Intent result = new Intent();
result.putExtra("delete", true);
setResult(Activity.RESULT_OK, result);
finish();
})
.setOnCancelListener(dialog -> finish())
.show();
new AlertDialog.Builder(new ContextThemeWrapper(this, DynamicTheme.isDarkTheme(this) ? R.style.TextSecure_DarkTheme : R.style.TextSecure_LightTheme))
.setMessage(titleId)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> finish())
.setPositiveButton(R.string.ClearProfileActivity_remove, (dialog, which) -> {
Intent result = new Intent();
result.putExtra("delete", true);
setResult(Activity.RESULT_OK, result);
finish();
})
.setOnCancelListener(dialog -> finish())
.show();
}
}

View File

@@ -19,20 +19,18 @@ package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.thoughtcrime.securesms.logging.Log;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
@@ -46,14 +44,14 @@ import java.lang.ref.WeakReference;
*/
public abstract class ContactSelectionActivity extends PassphraseRequiredActionBarActivity
implements SwipeRefreshLayout.OnRefreshListener,
ContactSelectionListFragment.OnContactSelectedListener
ContactSelectionListFragment.OnContactSelectedListener,
ContactSelectionListFragment.ScrollCallback
{
private static final String TAG = ContactSelectionActivity.class.getSimpleName();
public static final String EXTRA_LAYOUT_RES_ID = "layout_res_id";
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
protected ContactSelectionListFragment contactsFragment;
@@ -62,7 +60,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
}
@Override
@@ -84,7 +81,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
public void onResume() {
super.onResume();
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
}
protected ContactFilterToolbar getToolbar() {
@@ -95,7 +91,6 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
this.toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
getSupportActionBar().setDisplayShowTitleEnabled(false);
getSupportActionBar().setIcon(null);
@@ -123,6 +118,17 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActionB
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
@Override
public void onBeginScroll() {
hideKeyboard();
}
private void hideKeyboard() {
ServiceUtil.getInputMethodManager(this)
.hideSoftInputFromWindow(toolbar.getWindowToken(), 0);
toolbar.clearFocus();
}
private static class RefreshDirectoryTask extends AsyncTask<Context, Void, Void> {
private final WeakReference<ContactSelectionActivity> activity;

View File

@@ -18,15 +18,18 @@ package org.thoughtcrime.securesms;
import android.Manifest;
import android.animation.LayoutTransition;
import android.annotation.SuppressLint;
import android.content.Context;
import android.database.Cursor;
import android.os.AsyncTask;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.animation.CycleInterpolator;
import android.widget.Button;
import android.widget.HorizontalScrollView;
import android.widget.TextView;
@@ -35,14 +38,21 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.transition.AutoTransition;
import androidx.transition.TransitionManager;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.material.chip.ChipGroup;
import com.pnikosis.materialishprogress.ProgressWheel;
@@ -61,12 +71,10 @@ import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.UsernameUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
@@ -76,6 +84,8 @@ import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Fragment for selecting a one or more contacts from a list.
@@ -89,13 +99,19 @@ public final class ContactSelectionListFragment extends Fragment
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
public static final String DISPLAY_MODE = "display_mode";
public static final String MULTI_SELECT = "multi_select";
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
private final Debouncer scrollDebounce = new Debouncer(100);
public static final int NO_LIMIT = Integer.MAX_VALUE;
public static final String DISPLAY_MODE = "display_mode";
public static final String MULTI_SELECT = "multi_select";
public static final String REFRESHABLE = "refreshable";
public static final String RECENTS = "recents";
public static final String TOTAL_CAPACITY = "total_capacity";
public static final String CURRENT_SELECTION = "current_selection";
private ConstraintLayout constraintLayout;
private TextView emptyText;
private OnContactSelectedListener onContactSelectedListener;
private SwipeRefreshLayout swipeRefresh;
@@ -109,11 +125,15 @@ public final class ContactSelectionListFragment extends Fragment
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
private ChipGroup chipGroup;
private HorizontalScrollView chipGroupScrollContainer;
private TextView groupLimit;
@Nullable private FixedViewsAdapter headerAdapter;
@Nullable private FixedViewsAdapter footerAdapter;
@Nullable private ListCallback listCallback;
@Nullable private ScrollCallback scrollCallback;
private GlideRequests glideRequests;
private int selectionLimit;
private Set<RecipientId> currentSelection;
@Override
public void onAttach(@NonNull Context context) {
@@ -122,6 +142,10 @@ public final class ContactSelectionListFragment extends Fragment
if (context instanceof ListCallback) {
listCallback = (ListCallback) context;
}
if (context instanceof ScrollCallback) {
scrollCallback = (ScrollCallback) context;
}
}
@Override
@@ -163,26 +187,46 @@ public final class ContactSelectionListFragment extends Fragment
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.contact_selection_list_fragment, container, false);
emptyText = ViewUtil.findById(view, android.R.id.empty);
recyclerView = ViewUtil.findById(view, R.id.recycler_view);
swipeRefresh = ViewUtil.findById(view, R.id.swipe_refresh);
fastScroller = ViewUtil.findById(view, R.id.fast_scroller);
emptyText = view.findViewById(android.R.id.empty);
recyclerView = view.findViewById(R.id.recycler_view);
swipeRefresh = view.findViewById(R.id.swipe_refresh);
fastScroller = view.findViewById(R.id.fast_scroller);
showContactsLayout = view.findViewById(R.id.show_contacts_container);
showContactsButton = view.findViewById(R.id.show_contacts_button);
showContactsDescription = view.findViewById(R.id.show_contacts_description);
showContactsProgress = view.findViewById(R.id.progress);
chipGroup = view.findViewById(R.id.chipGroup);
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
groupLimit = view.findViewById(R.id.group_limit);
constraintLayout = view.findViewById(R.id.container);
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
recyclerView.setItemAnimator(new DefaultItemAnimator() {
@Override
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
return true;
}
});
swipeRefresh.setEnabled(requireActivity().getIntent().getBooleanExtra(REFRESHABLE, true));
autoScrollOnNewItem();
selectionLimit = requireActivity().getIntent().getIntExtra(TOTAL_CAPACITY, NO_LIMIT);
currentSelection = getCurrentSelection();
updateGroupLimit(getChipCount());
return view;
}
private void updateGroupLimit(int chipCount) {
if (selectionLimit != NO_LIMIT) {
groupLimit.setText(String.format(Locale.getDefault(), "%d/%d", currentSelection.size() + chipCount, selectionLimit));
groupLimit.setVisibility(View.VISIBLE);
} else {
groupLimit.setVisibility(View.GONE);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
@@ -204,6 +248,13 @@ public final class ContactSelectionListFragment extends Fragment
return cursorRecyclerViewAdapter.getSelectedContactsCount();
}
private Set<RecipientId> getCurrentSelection() {
List<RecipientId> currentSelection = requireActivity().getIntent().getParcelableArrayListExtra(CURRENT_SELECTION);
return currentSelection == null ? Collections.emptySet()
: Collections.unmodifiableSet(Stream.of(currentSelection).collect(Collectors.toSet()));
}
private boolean isMulti() {
return requireActivity().getIntent().getBooleanExtra(MULTI_SELECT, false);
}
@@ -215,12 +266,17 @@ public final class ContactSelectionListFragment extends Fragment
glideRequests,
null,
new ListClickListener(),
isMulti());
isMulti(),
currentSelection);
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
if (listCallback != null && FeatureFlags.newGroupUI()) {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
if (FeatureFlags.groupsV2create() && FeatureFlags.groupsV2internalTest()) {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback), createNewGroupsV1GroupItem(listCallback));
} else {
headerAdapter = new FixedViewsAdapter(createNewGroupItem(listCallback));
}
headerAdapter.hide();
concatenateAdapter.addAdapter(headerAdapter);
}
@@ -235,6 +291,16 @@ public final class ContactSelectionListFragment extends Fragment
recyclerView.setAdapter(concatenateAdapter);
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true));
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
if (newState == RecyclerView.SCROLL_STATE_DRAGGING) {
if (scrollCallback != null) {
scrollCallback.onBeginScroll();
}
}
}
});
}
private View createInviteActionView(@NonNull ListCallback listCallback) {
@@ -247,7 +313,14 @@ public final class ContactSelectionListFragment extends Fragment
private View createNewGroupItem(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_new_group_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onNewGroup());
view.setOnClickListener(v -> listCallback.onNewGroup(false));
return view;
}
private View createNewGroupsV1GroupItem(@NonNull ListCallback listCallback) {
View view = LayoutInflater.from(requireContext())
.inflate(R.layout.contact_selection_new_group_v1_item, (ViewGroup) requireView(), false);
view.setOnClickListener(v -> listCallback.onNewGroup(true));
return view;
}
@@ -283,6 +356,10 @@ public final class ContactSelectionListFragment extends Fragment
swipeRefresh.setRefreshing(false);
}
public boolean hasQueryFilter() {
return !TextUtils.isEmpty(cursorFilter);
}
public void setRefreshing(boolean refreshing) {
swipeRefresh.setRefreshing(refreshing);
}
@@ -291,15 +368,16 @@ public final class ContactSelectionListFragment extends Fragment
cursorRecyclerViewAdapter.clearSelectedContacts();
if (!isDetached() && !isRemoving() && getActivity() != null && !getActivity().isFinishing()) {
getLoaderManager().restartLoader(0, null, this);
LoaderManager.getInstance(this).restartLoader(0, null, this);
}
}
@Override
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new ContactsCursorLoader(getActivity(),
getActivity().getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL),
cursorFilter, getActivity().getIntent().getBooleanExtra(RECENTS, false));
FragmentActivity activity = requireActivity();
return new ContactsCursorLoader(activity,
activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL),
cursorFilter, activity.getIntent().getBooleanExtra(RECENTS, false));
}
@Override
@@ -314,7 +392,11 @@ public final class ContactSelectionListFragment extends Fragment
}
if (headerAdapter != null) {
headerAdapter.show();
if (TextUtils.isEmpty(cursorFilter)) {
headerAdapter.show();
} else {
headerAdapter.hide();
}
}
emptyText.setText(R.string.contact_selection_group_activity__no_contacts);
@@ -382,6 +464,12 @@ public final class ContactSelectionListFragment extends Fragment
: SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
if (!isMulti() || !cursorRecyclerViewAdapter.isSelectedContact(selectedContact)) {
if (selectionLimitReached()) {
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_the_group_is_full, Toast.LENGTH_SHORT).show();
groupLimit.animate().scaleX(1.3f).scaleY(1.3f).setInterpolator(new CycleInterpolator(0.5f)).start();
return;
}
if (contact.isUsernameType()) {
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
@@ -392,7 +480,8 @@ public final class ContactSelectionListFragment extends Fragment
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
markContactSelected(selected, contact);
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
@@ -406,14 +495,16 @@ public final class ContactSelectionListFragment extends Fragment
}
});
} else {
markContactSelected(selectedContact, contact);
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
}
}
} else {
markContactUnselected(selectedContact, contact);
markContactUnselected(selectedContact);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(contact.getRecipientId(), contact.getNumber());
@@ -421,17 +512,20 @@ public final class ContactSelectionListFragment extends Fragment
}}
}
private void markContactSelected(@NonNull SelectedContact selectedContact, @NonNull ContactSelectionListItem listItem) {
private boolean selectionLimitReached() {
return getChipCount() >= selectionLimit;
}
private void markContactSelected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.addSelectedContact(selectedContact);
listItem.setChecked(true);
if (isMulti() && FeatureFlags.newGroupUI()) {
chipGroup.addView(newChipForContact(listItem, selectedContact));
addChipForSelectedContact(selectedContact);
}
}
private void markContactUnselected(@NonNull SelectedContact selectedContact, @NonNull ContactSelectionListItem listItem) {
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
listItem.setChecked(false);
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
removeChipForContact(selectedContact);
}
@@ -442,28 +536,85 @@ public final class ContactSelectionListFragment extends Fragment
chipGroup.removeView(v);
}
}
updateGroupLimit(getChipCount());
if (getChipCount() == 0) {
setChipGroupVisibility(ConstraintSet.GONE);
}
}
private View newChipForContact(@NonNull ContactSelectionListItem contact, @NonNull SelectedContact selectedContact) {
final ContactChip chip = new ContactChip(requireContext());
chip.setText(contact.getChipName());
chip.setContact(selectedContact);
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
resolved -> addChipForRecipient(resolved, selectedContact));
}
LiveRecipient recipient = contact.getRecipient();
if (recipient != null) {
recipient.observe(getViewLifecycleOwner(), resolved -> {
chip.setAvatar(glideRequests, resolved);
chip.setText(resolved.getShortDisplayName(chip.getContext()));
}
);
private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
final ContactChip chip = new ContactChip(requireContext());
if (getChipCount() == 0) {
setChipGroupVisibility(ConstraintSet.VISIBLE);
}
chip.setText(recipient.getShortDisplayName(requireContext()));
chip.setContact(selectedContact);
chip.setCloseIconVisible(true);
chip.setOnCloseIconClickListener(view -> {
markContactUnselected(selectedContact, contact);
chipGroup.removeView(chip);
markContactUnselected(selectedContact);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orNull());
}
});
return chip;
chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
@Override
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
}
@Override
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
if (view == chip && transitionType == LayoutTransition.APPEARING) {
chipGroup.getLayoutTransition().removeTransitionListener(this);
registerChipRecipientObserver(chip, recipient.live());
chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
}
}
});
chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
}
private void addChip(@NonNull ContactChip chip) {
chipGroup.addView(chip);
updateGroupLimit(getChipCount());
}
private int getChipCount() {
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
if (count < 0) throw new AssertionError();
return count;
}
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
if (recipient != null) {
recipient.observe(getViewLifecycleOwner(), resolved -> {
if (chip.isAttachedToWindow()) {
chip.setAvatar(glideRequests, resolved, null);
chip.setText(resolved.getShortDisplayName(chip.getContext()));
}
});
}
}
private void setChipGroupVisibility(int visibility) {
TransitionManager.beginDelayedTransition(constraintLayout, new AutoTransition().setDuration(CHIP_GROUP_REVEAL_DURATION_MS));
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(constraintLayout);
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
constraintSet.applyTo(constraintLayout);
}
public void setOnContactSelectedListener(OnContactSelectedListener onContactSelectedListener) {
@@ -474,14 +625,6 @@ public final class ContactSelectionListFragment extends Fragment
this.swipeRefresh.setOnRefreshListener(onRefreshListener);
}
private void autoScrollOnNewItem() {
chipGroup.addOnLayoutChangeListener((view1, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
if (right > oldRight) {
scrollDebounce.publish(this::smoothScrollChipsToEnd);
}
});
}
private void smoothScrollChipsToEnd() {
int x = chipGroupScrollContainer.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? chipGroup.getWidth() : 0;
chipGroupScrollContainer.smoothScrollTo(x, 0);
@@ -494,6 +637,10 @@ public final class ContactSelectionListFragment extends Fragment
public interface ListCallback {
void onInvite();
void onNewGroup();
void onNewGroup(boolean forceV1);
}
public interface ScrollCallback {
void onBeginScroll();
}
}

View File

@@ -164,25 +164,29 @@ public class DeviceListFragment extends ListFragment
@SuppressLint("StaticFieldLeak")
private void handleDisconnectDevice(final long deviceId) {
new ProgressDialogAsyncTask<Void, Void, Void>(getActivity(),
R.string.DeviceListActivity_unlinking_device_no_ellipsis,
R.string.DeviceListActivity_unlinking_device)
new ProgressDialogAsyncTask<Void, Void, Boolean>(getActivity(),
R.string.DeviceListActivity_unlinking_device_no_ellipsis,
R.string.DeviceListActivity_unlinking_device)
{
@Override
protected Void doInBackground(Void... params) {
protected Boolean doInBackground(Void... params) {
try {
accountManager.removeDevice(deviceId);
return true;
} catch (IOException e) {
Log.w(TAG, e);
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
return false;
}
return null;
}
@Override
protected void onPostExecute(Void result) {
protected void onPostExecute(Boolean result) {
super.onPostExecute(result);
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
if (result) {
getLoaderManager().restartLoader(0, null, DeviceListFragment.this);
} else {
Toast.makeText(getActivity(), R.string.DeviceListActivity_network_failed, Toast.LENGTH_LONG).show();
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
}

View File

@@ -17,9 +17,6 @@ public class DeviceProvisioningActivity extends PassphraseRequiredActionBarActiv
@Override
protected void onCreate(Bundle bundle, boolean ready) {
assert getSupportActionBar() != null;
getSupportActionBar().hide();
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle(getString(R.string.DeviceProvisioningActivity_link_a_signal_device))
.setMessage(getString(R.string.DeviceProvisioningActivity_it_looks_like_youre_trying_to_link_a_signal_device_using_a_3rd_party_scanner))

View File

@@ -56,8 +56,8 @@ public class MainNavigator {
return false;
}
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition) {
Intent intent = ConversationActivity.buildIntent(activity, recipientId, threadId, distributionType, startingPosition);
public void goToConversation(@NonNull RecipientId recipientId, long threadId, int distributionType, int startingPosition, boolean highlightStartPosition) {
Intent intent = ConversationActivity.buildIntent(activity, recipientId, threadId, distributionType, startingPosition, highlightStartPosition);
activity.startActivity(intent);
activity.overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out);

View File

@@ -32,7 +32,10 @@ import androidx.loader.content.Loader;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import android.os.Parcelable;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
@@ -52,7 +55,6 @@ import org.thoughtcrime.securesms.database.loaders.MessageDetailsLoader;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
@@ -133,13 +135,13 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
assert getSupportActionBar() != null;
getSupportActionBar().setTitle(R.string.AndroidManifest__message_details);
MessageNotifier.setVisibleThread(threadId);
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
}
@Override
protected void onPause() {
super.onPause();
MessageNotifier.setVisibleThread(-1L);
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
}
@Override
@@ -270,7 +272,9 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
}
toFrom.setText(toFromRes);
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, dynamicLanguage.getCurrentLocale(), new HashSet<>(), recipient, null, false);
Parcelable state = recipientsList.onSaveInstanceState();
recipientsList.setAdapter(new MessageDetailsRecipientAdapter(this, glideRequests, messageRecord, recipients, isPushGroup));
recipientsList.onRestoreInstanceState(state);
}
private void inflateMessageViewIfAbsent(MessageRecord messageRecord) {
@@ -278,9 +282,9 @@ public class MessageDetailsActivity extends PassphraseRequiredActionBarActivity
if (messageRecord.isGroupAction()) {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_update, itemParent, false);
} else if (messageRecord.isOutgoing()) {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent, itemParent, false);
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_sent_multimedia, itemParent, false);
} else {
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received, itemParent, false);
conversationItem = (ConversationItem) inflater.inflate(R.layout.conversation_item_received_multimedia, itemParent, false);
}
itemParent.addView(conversationItem);
}

View File

@@ -81,23 +81,45 @@ public class MessageRecipientListItem extends RelativeLayout
this.deliveryStatusView = findViewById(R.id.delivery_status);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
observeMember();
}
@Override
protected void onDetachedFromWindow() {
unsubscribeFromMember();
super.onDetachedFromWindow();
}
public void set(final GlideRequests glideRequests,
final MessageRecord record,
final RecipientDeliveryStatus member,
final boolean isPushGroup)
{
if (this.member != null) this.member.getRecipient().live().removeForeverObserver(this);
unsubscribeFromMember();
this.glideRequests = glideRequests;
this.member = member;
observeMember();
member.getRecipient().live().observeForever(this);
fromView.setText(member.getRecipient());
contactPhotoImage.setAvatar(glideRequests, member.getRecipient(), false);
setIssueIndicators(record, isPushGroup);
unidentifiedDeliveryIcon.setVisibility(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(getContext()) && member.isUnidentified() ? VISIBLE : GONE);
}
private void observeMember() {
if (isAttachedToWindow() && member != null && member.getRecipient() != null) {
member.getRecipient().live().observeForever(this);
}
}
private void unsubscribeFromMember() {
if (member != null && member.getRecipient() != null) member.getRecipient().live().removeForeverObserver(this);
}
private void setIssueIndicators(final MessageRecord record,
final boolean isPushGroup)
{
@@ -162,7 +184,7 @@ public class MessageRecipientListItem extends RelativeLayout
}
public void unbind() {
if (this.member != null && this.member.getRecipient() != null) this.member.getRecipient().live().removeForeverObserver(this);
unsubscribeFromMember();
}
@Override

View File

@@ -19,9 +19,7 @@ package org.thoughtcrime.securesms;
import android.content.Intent;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import org.thoughtcrime.securesms.conversation.ConversationActivity;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -121,7 +119,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onNewGroup() {
public void onNewGroup(boolean forceV1) {
handleCreateGroup();
finish();
}

View File

@@ -45,16 +45,24 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
getIntent().putExtra(ContactSelectionListFragment.MULTI_SELECT, true);
super.onCreate(icicle, ready);
initializeToolbar();
}
protected void initializeToolbar() {
getToolbar().setNavigationIcon(R.drawable.ic_check_24);
getToolbar().setNavigationOnClickListener(v -> {
Intent resultIntent = getIntent();
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
setResult(RESULT_OK, resultIntent);
finish();
onFinishedSelection();
});
}
protected final void onFinishedSelection() {
Intent resultIntent = getIntent();
List<SelectedContact> selectedContacts = contactsFragment.getSelectedContacts();
List<RecipientId> recipients = Stream.of(selectedContacts).map(sc -> sc.getOrCreateRecipientId(this)).toList();
resultIntent.putParcelableArrayListExtra(KEY_SELECTED_RECIPIENTS, new ArrayList<>(recipients));
setResult(RESULT_OK, resultIntent);
finish();
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.media.Ringtone;
import android.media.RingtoneManager;
import android.net.Uri;
@@ -35,7 +36,11 @@ import androidx.preference.Preference;
import androidx.preference.PreferenceCategory;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.Target;
import com.google.android.material.appbar.CollapsingToolbarLayout;
import org.thoughtcrime.securesms.color.MaterialColor;
@@ -69,7 +74,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
@@ -100,8 +104,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
private static final String PREFERENCE_ABOUT = "pref_key_number";
private static final String PREFERENCE_CUSTOM_NOTIFICATIONS = "pref_key_recipient_custom_notifications";
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
private final DynamicLanguage dynamicLanguage = new DynamicLanguage();
private final DynamicTheme dynamicTheme = new DynamicDarkToolbarTheme();
private ImageView avatar;
private GlideRequests glideRequests;
@@ -120,7 +123,6 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
@Override
public void onPreCreate() {
dynamicTheme.onCreate(this);
dynamicLanguage.onCreate(this);
}
@Override
@@ -135,14 +137,13 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
setHeader(recipient.get());
recipient.observe(this, this::setHeader);
getSupportLoaderManager().initLoader(0, null, this);
LoaderManager.getInstance(this).initLoader(0, null, this);
}
@Override
public void onResume() {
super.onResume();
dynamicTheme.onResume(this);
dynamicLanguage.onResume(this);
}
@Override
@@ -165,10 +166,10 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
}
private void initializeToolbar() {
this.toolbarLayout = ViewUtil.findById(this, R.id.collapsing_toolbar);
this.avatar = ViewUtil.findById(this, R.id.avatar);
this.threadPhotoRailView = ViewUtil.findById(this, R.id.recent_photos);
this.threadPhotoRailLabel = ViewUtil.findById(this, R.id.rail_label);
this.toolbarLayout = findViewById(R.id.collapsing_toolbar);
this.avatar = findViewById(R.id.avatar);
this.threadPhotoRailView = findViewById(R.id.recent_photos);
this.threadPhotoRailLabel = findViewById(R.id.rail_label);
this.toolbarLayout.setExpandedTitleColor(ThemeUtil.getThemedColor(this, R.attr.conversation_title_color));
this.toolbarLayout.setCollapsedTitleTextColor(ThemeUtil.getThemedColor(this, R.attr.conversation_title_color));
@@ -189,7 +190,7 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
}
);
Toolbar toolbar = ViewUtil.findById(this, R.id.toolbar);
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
getSupportActionBar().setLogo(null);
@@ -215,6 +216,20 @@ public class RecipientPreferenceActivity extends PassphraseRequiredActionBarActi
.fallback(fallbackPhoto.asCallCard(this))
.error(fallbackPhoto.asCallCard(this))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.addListener(new RequestListener<Drawable>() {
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
avatar.setOnClickListener(null);
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
avatar.setOnClickListener(v -> startActivity(AvatarPreviewActivity.intentFromRecipientId(RecipientPreferenceActivity.this, recipient.getId()),
AvatarPreviewActivity.createTransitionBundle(RecipientPreferenceActivity.this, avatar)));
return false;
}
})
.into(this.avatar);
if (contactPhoto == null) this.avatar.setScaleType(ImageView.ScaleType.CENTER_INSIDE);

View File

@@ -203,8 +203,6 @@ public class WebRtcCallActivity extends AppCompatActivity {
viewModel = ViewModelProviders.of(this).get(WebRtcCallViewModel.class);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getRemoteVideoEnabled().observe(this,callScreen::setRemoteVideoEnabled);
viewModel.getBluetoothEnabled().observe(this, callScreen::setBluetoothEnabled);
viewModel.getAudioOutput().observe(this, callScreen::setAudioOutput);
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getCameraDirection().observe(this, callScreen::setCameraDirection);
viewModel.getLocalRenderState().observe(this, callScreen::setLocalRenderState);
@@ -212,7 +210,6 @@ public class WebRtcCallActivity extends AppCompatActivity {
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
viewModel.displaySquareCallCard().observe(this, callScreen::showCallCard);
viewModel.isMoreThanOneCameraAvailable().observe(this, callScreen::showCameraToggleButton);
}
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
@@ -253,17 +250,23 @@ public class WebRtcCallActivity extends AppCompatActivity {
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
}
private void handleSetAudioSpeaker(boolean enabled) {
private void handleSetAudioHandset() {
Intent intent = new Intent(this, WebRtcCallService.class);
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, enabled);
startService(intent);
}
private void handleSetAudioBluetooth(boolean enabled) {
private void handleSetAudioSpeaker() {
Intent intent = new Intent(this, WebRtcCallService.class);
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_SPEAKER);
intent.putExtra(WebRtcCallService.EXTRA_SPEAKER, true);
startService(intent);
}
private void handleSetAudioBluetooth() {
Intent intent = new Intent(this, WebRtcCallService.class);
intent.setAction(WebRtcCallService.ACTION_SET_AUDIO_BLUETOOTH);
intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, enabled);
intent.putExtra(WebRtcCallService.EXTRA_BLUETOOTH, true);
startService(intent);
}
@@ -546,13 +549,13 @@ public class WebRtcCallActivity extends AppCompatActivity {
public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
switch (audioOutput) {
case HANDSET:
handleSetAudioSpeaker(false);
handleSetAudioHandset();
break;
case HEADSET:
handleSetAudioBluetooth(true);
handleSetAudioBluetooth();
break;
case SPEAKER:
handleSetAudioSpeaker(true);
handleSetAudioSpeaker();
break;
default:
throw new IllegalStateException("Unknown output: " + audioOutput);

View File

@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.attachments;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
@@ -51,6 +53,9 @@ public abstract class Attachment {
@Nullable
private final BlurHash blurHash;
@Nullable
private final AudioHash audioHash;
@NonNull
private final TransformProperties transformProperties;
@@ -58,7 +63,7 @@ public abstract class Attachment {
int cdnNumber, @Nullable String location, @Nullable String key, @Nullable String relay,
@Nullable byte[] digest, @Nullable String fastPreflightId, boolean voiceNote,
int width, int height, boolean quote, long uploadTimestamp, @Nullable String caption,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
@Nullable TransformProperties transformProperties)
{
this.contentType = contentType;
@@ -79,6 +84,7 @@ public abstract class Attachment {
this.stickerLocator = stickerLocator;
this.caption = caption;
this.blurHash = blurHash;
this.audioHash = audioHash;
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
}
@@ -172,6 +178,10 @@ public abstract class Attachment {
return blurHash;
}
public @Nullable AudioHash getAudioHash() {
return audioHash;
}
public @Nullable String getCaption() {
return caption;
}

View File

@@ -4,6 +4,7 @@ import android.net.Uri;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
import org.thoughtcrime.securesms.mms.PartAuthority;
@@ -25,11 +26,11 @@ public class DatabaseAttachment extends Attachment {
String fileName, int cdnNumber, String location, String key, String relay,
byte[] digest, String fastPreflightId, boolean voiceNote,
int width, int height, boolean quote, @Nullable String caption,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash,
@Nullable TransformProperties transformProperties, int displayOrder,
long uploadTimestamp)
{
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, transformProperties);
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
this.attachmentId = attachmentId;
this.hasData = hasData;
this.hasThumbnail = hasThumbnail;

View File

@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.database.MmsDatabase;
public class MmsNotificationAttachment extends Attachment {
public MmsNotificationAttachment(int status, long size) {
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, 0, 0, false, 0, null, null, null, null);
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, false, 0, 0, false, 0, null, null, null, null, null);
}
@Nullable

View File

@@ -4,6 +4,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -24,7 +25,7 @@ public class PointerAttachment extends Attachment {
int width, int height, long uploadTimestamp, @Nullable String caption, @Nullable StickerLocator stickerLocator,
@Nullable BlurHash blurHash)
{
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null);
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, fastPreflightId, voiceNote, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
}
@Nullable

View File

@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
public class TombstoneAttachment extends Attachment {
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, 0, 0, quote, 0, null, null, null, null);
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, 0, 0, quote, 0, null, null, null, null, null);
}
@Override

View File

@@ -5,6 +5,7 @@ import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -16,18 +17,18 @@ public class UriAttachment extends Attachment {
public UriAttachment(@NonNull Uri uri, @NonNull String contentType, int transferState, long size,
@Nullable String fileName, boolean voiceNote, boolean quote, @Nullable String caption,
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties)
@Nullable StickerLocator stickerLocator, @Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
{
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, stickerLocator, blurHash, transformProperties);
this(uri, uri, contentType, transferState, size, 0, 0, fileName, null, voiceNote, quote, caption, stickerLocator, blurHash, audioHash, transformProperties);
}
public UriAttachment(@NonNull Uri dataUri, @Nullable Uri thumbnailUri,
@NonNull String contentType, int transferState, long size, int width, int height,
@Nullable String fileName, @Nullable String fastPreflightId,
boolean voiceNote, boolean quote, @Nullable String caption, @Nullable StickerLocator stickerLocator,
@Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties)
@Nullable BlurHash blurHash, @Nullable AudioHash audioHash, @Nullable TransformProperties transformProperties)
{
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, 0, caption, stickerLocator, blurHash, transformProperties);
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
this.dataUri = dataUri;
this.thumbnailUri = thumbnailUri;
}

View File

@@ -0,0 +1,57 @@
package org.thoughtcrime.securesms.audio;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.whispersystems.util.Base64;
import java.io.IOException;
/**
* An AudioHash is a compact string representation of the wave form and duration for an audio file.
*/
public final class AudioHash {
@NonNull private final String hash;
@NonNull private final AudioWaveFormData audioWaveForm;
private AudioHash(@NonNull String hash, @NonNull AudioWaveFormData audioWaveForm) {
this.hash = hash;
this.audioWaveForm = audioWaveForm;
}
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm);
}
public static @Nullable AudioHash parseOrNull(@Nullable String hash) {
if (hash == null) return null;
try {
return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash)));
} catch (IOException e) {
return null;
}
}
@NonNull AudioWaveFormData getAudioWaveForm() {
return audioWaveForm;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
AudioHash other = (AudioHash) o;
return hash.equals(other.hash);
}
@Override
public int hashCode() {
return hash.hashCode();
}
public @NonNull String getHash() {
return hash;
}
}

View File

@@ -0,0 +1,300 @@
package org.thoughtcrime.securesms.audio;
import android.content.Context;
import android.media.MediaCodec;
import android.media.MediaExtractor;
import android.media.MediaFormat;
import android.net.Uri;
import android.os.Build;
import android.util.LruCache;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.annotation.WorkerThread;
import androidx.core.util.Consumer;
import com.google.protobuf.ByteString;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
import org.thoughtcrime.securesms.media.MediaInput;
import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Locale;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@RequiresApi(api = Build.VERSION_CODES.M)
public final class AudioWaveForm {
private static final String TAG = Log.tag(AudioWaveForm.class);
private static final int BAR_COUNT = 46;
private static final int SAMPLES_PER_BAR = 4;
private final Context context;
private final AudioSlide slide;
public AudioWaveForm(@NonNull Context context, @NonNull AudioSlide slide) {
this.context = context.getApplicationContext();
this.slide = slide;
}
private static final LruCache<String, AudioFileInfo> WAVE_FORM_CACHE = new LruCache<>(200);
private static final Executor AUDIO_DECODER_EXECUTOR = new SerialExecutor(SignalExecutors.BOUNDED);
@AnyThread
public void getWaveForm(@NonNull Consumer<AudioFileInfo> onSuccess, @NonNull Runnable onFailure) {
Uri uri = slide.getUri();
Attachment attachment = slide.asAttachment();
if (uri == null) {
Log.w(TAG, "No uri");
Util.runOnMain(onFailure);
return;
}
if (!(attachment instanceof DatabaseAttachment)) {
Log.i(TAG, "Not yet in database");
Util.runOnMain(onFailure);
return;
}
String cacheKey = uri.toString();
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
if (cached != null) {
Log.i(TAG, "Loaded wave form from cache " + cacheKey);
Util.runOnMain(() -> onSuccess.accept(cached));
return;
}
AUDIO_DECODER_EXECUTOR.execute(() -> {
AudioFileInfo cachedInExecutor = WAVE_FORM_CACHE.get(cacheKey);
if (cachedInExecutor != null) {
Log.i(TAG, "Loaded wave form from cache inside executor" + cacheKey);
Util.runOnMain(() -> onSuccess.accept(cachedInExecutor));
return;
}
AudioHash audioHash = attachment.getAudioHash();
if (audioHash != null) {
AudioFileInfo audioFileInfo = AudioFileInfo.fromDatabaseProtobuf(audioHash.getAudioWaveForm());
if (audioFileInfo.waveForm.length != BAR_COUNT) {
Log.w(TAG, "Wave form from database does not match bar count, regenerating " + cacheKey);
} else {
WAVE_FORM_CACHE.put(cacheKey, audioFileInfo);
Log.i(TAG, "Loaded wave form from DB " + cacheKey);
Util.runOnMain(() -> onSuccess.accept(audioFileInfo));
return;
}
}
try {
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
long startTime = System.currentTimeMillis();
AudioFileInfo fileInfo = generateWaveForm(uri);
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
DatabaseFactory.getAttachmentDatabase(context).writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
Util.runOnMain(() -> onSuccess.accept(fileInfo));
} catch (Throwable e) {
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
Util.runOnMain(onFailure);
}
});
}
/**
* Based on decode sample from:
* <p>
* https://android.googlesource.com/platform/cts/+/jb-mr2-release/tests/tests/media/src/android/media/cts/DecoderTest.java
*/
@WorkerThread
@RequiresApi(api = 23)
private @NonNull AudioFileInfo generateWaveForm(@NonNull Uri uri) throws IOException {
try (MediaInput dataSource = DecryptableUriMediaInput.createForUri(context, uri)) {
long[] wave = new long[BAR_COUNT];
int[] waveSamples = new int[BAR_COUNT];
MediaExtractor extractor = dataSource.createExtractor();
if (extractor.getTrackCount() == 0) {
throw new IOException("No audio track");
}
MediaFormat format = extractor.getTrackFormat(0);
if (!format.containsKey(MediaFormat.KEY_DURATION)) {
throw new IOException("Unknown duration");
}
long totalDurationUs = format.getLong(MediaFormat.KEY_DURATION);
String mime = format.getString(MediaFormat.KEY_MIME);
if (!mime.startsWith("audio/")) {
throw new IOException("Mime not audio");
}
MediaCodec codec = MediaCodec.createDecoderByType(mime);
if (totalDurationUs == 0) {
throw new IOException("Zero duration");
}
codec.configure(format, null, null, 0);
codec.start();
ByteBuffer[] codecInputBuffers = codec.getInputBuffers();
ByteBuffer[] codecOutputBuffers = codec.getOutputBuffers();
extractor.selectTrack(0);
long kTimeOutUs = 5000;
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
boolean sawInputEOS = false;
boolean sawOutputEOS = false;
int noOutputCounter = 0;
while (!sawOutputEOS && noOutputCounter < 50) {
noOutputCounter++;
if (!sawInputEOS) {
int inputBufIndex = codec.dequeueInputBuffer(kTimeOutUs);
if (inputBufIndex >= 0) {
ByteBuffer dstBuf = codecInputBuffers[inputBufIndex];
int sampleSize = extractor.readSampleData(dstBuf, 0);
long presentationTimeUs = 0;
if (sampleSize < 0) {
sawInputEOS = true;
sampleSize = 0;
} else {
presentationTimeUs = extractor.getSampleTime();
}
codec.queueInputBuffer(
inputBufIndex,
0,
sampleSize,
presentationTimeUs,
sawInputEOS ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0);
if (!sawInputEOS) {
int barSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
sawInputEOS = !extractor.advance();
int nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
while (!sawInputEOS && nextBarSampleIndex == barSampleIndex) {
sawInputEOS = !extractor.advance();
if (!sawInputEOS) {
nextBarSampleIndex = (int) (SAMPLES_PER_BAR * (wave.length * extractor.getSampleTime()) / totalDurationUs);
}
}
}
}
}
int outputBufferIndex;
do {
outputBufferIndex = codec.dequeueOutputBuffer(info, kTimeOutUs);
if (outputBufferIndex >= 0) {
if (info.size > 0) {
noOutputCounter = 0;
}
ByteBuffer buf = codecOutputBuffers[outputBufferIndex];
int barIndex = (int) ((wave.length * info.presentationTimeUs) / totalDurationUs);
long total = 0;
for (int i = 0; i < info.size; i += 2 * 4) {
short aShort = buf.getShort(i);
total += Math.abs(aShort);
}
if (barIndex >= 0 && barIndex < wave.length) {
wave[barIndex] += total;
waveSamples[barIndex] += info.size / 2;
}
codec.releaseOutputBuffer(outputBufferIndex, false);
if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
sawOutputEOS = true;
}
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
codecOutputBuffers = codec.getOutputBuffers();
} else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
Log.d(TAG, "output format has changed to " + codec.getOutputFormat());
}
} while (outputBufferIndex >= 0);
}
codec.stop();
codec.release();
extractor.release();
float[] floats = new float[BAR_COUNT];
byte[] bytes = new byte[BAR_COUNT];
float max = 0;
for (int i = 0; i < BAR_COUNT; i++) {
if (waveSamples[i] == 0) continue;
floats[i] = wave[i] / (float) waveSamples[i];
if (floats[i] > max) {
max = floats[i];
}
}
for (int i = 0; i < BAR_COUNT; i++) {
float normalized = floats[i] / max;
bytes[i] = (byte) (255 * normalized);
}
return new AudioFileInfo(totalDurationUs, bytes);
}
}
public static class AudioFileInfo {
private final long durationUs;
private final byte[] waveFormBytes;
private final float[] waveForm;
private static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
}
private AudioFileInfo(long durationUs, byte[] waveFormBytes) {
this.durationUs = durationUs;
this.waveFormBytes = waveFormBytes;
this.waveForm = new float[waveFormBytes.length];
for (int i = 0; i < waveFormBytes.length; i++) {
int unsigned = waveFormBytes[i] & 0xff;
this.waveForm[i] = unsigned / 255f;
}
}
public long getDuration(@NonNull TimeUnit timeUnit) {
return timeUnit.convert(durationUs, TimeUnit.MICROSECONDS);
}
public float[] getWaveForm() {
return waveForm;
}
private @NonNull AudioWaveFormData toDatabaseProtobuf() {
return AudioWaveFormData.newBuilder()
.setDurationUs(durationUs)
.setWaveForm(ByteString.copyFrom(waveFormBytes))
.build();
}
}
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.color;
import android.content.Context;
import android.graphics.Color;
import androidx.annotation.ColorInt;
import androidx.annotation.ColorRes;
import androidx.annotation.NonNull;

View File

@@ -8,12 +8,12 @@ import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.TextView;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,6 +29,7 @@ import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.audio.AudioSlidePlayer;
import org.thoughtcrime.securesms.audio.AudioWaveForm;
import org.thoughtcrime.securesms.database.AttachmentDatabase;
import org.thoughtcrime.securesms.events.PartProgressEvent;
import org.thoughtcrime.securesms.logging.Log;
@@ -36,7 +37,7 @@ import org.thoughtcrime.securesms.mms.AudioSlide;
import org.thoughtcrime.securesms.mms.SlideClickListener;
import java.io.IOException;
import java.util.Locale;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public final class AudioView extends FrameLayout implements AudioSlidePlayer.Listener {
@@ -47,7 +48,6 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
private static final int REVERSE = -1;
@NonNull private final AnimatingToggle controlToggle;
@NonNull private final ViewGroup container;
@NonNull private final View progressAndPlay;
@NonNull private final LottieAnimationView playPauseButton;
@NonNull private final ImageView downloadButton;
@@ -56,13 +56,17 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
private final boolean smallView;
private final boolean autoRewind;
@Nullable private final TextView timestamp;
@Nullable private final TextView duration;
@ColorInt private final int waveFormPlayedBarsColor;
@ColorInt private final int waveFormUnplayedBarsColor;
@Nullable private SlideClickListener downloadListener;
@Nullable private AudioSlidePlayer audioSlidePlayer;
private int backwardsCounter;
private int lottieDirection;
private boolean isPlaying;
private long durationMillis;
public AudioView(Context context) {
this(context, null);
@@ -83,22 +87,22 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
inflate(context, smallView ? R.layout.audio_view_small : R.layout.audio_view, this);
this.container = findViewById(R.id.audio_widget_container);
this.controlToggle = findViewById(R.id.control_toggle);
this.playPauseButton = findViewById(R.id.play);
this.progressAndPlay = findViewById(R.id.progress_and_play);
this.downloadButton = findViewById(R.id.download);
this.circleProgress = findViewById(R.id.circle_progress);
this.seekBar = findViewById(R.id.seek);
this.timestamp = findViewById(R.id.timestamp);
this.duration = findViewById(R.id.duration);
lottieDirection = REVERSE;
this.playPauseButton.setOnClickListener(new PlayPauseClickedListener());
this.seekBar.setOnSeekBarChangeListener(new SeekBarModifiedListener());
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE),
typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.WHITE));
container.setBackgroundColor(typedArray.getColor(R.styleable.AudioView_widgetBackground, Color.TRANSPARENT));
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE));
this.waveFormPlayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformPlayedBarsColor, Color.WHITE);
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
} finally {
if (typedArray != null) {
typedArray.recycle();
@@ -121,6 +125,14 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
public void setAudio(final @NonNull AudioSlide audio,
final boolean showControls)
{
if (seekBar instanceof WaveFormSeekBarView) {
if (audioSlidePlayer != null && !Objects.equals(audioSlidePlayer.getAudioSlide().getUri(), audio.getUri())) {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
waveFormView.setWaveMode(false);
seekBar.setProgress(0);
durationMillis = 0;
}
}
if (showControls && audio.isPendingDownload()) {
controlToggle.displayQuick(downloadButton);
@@ -141,6 +153,28 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
}
this.audioSlidePlayer = AudioSlidePlayer.createFor(getContext(), audio, this);
if (seekBar instanceof WaveFormSeekBarView) {
WaveFormSeekBarView waveFormView = (WaveFormSeekBarView) seekBar;
waveFormView.setColors(waveFormPlayedBarsColor, waveFormUnplayedBarsColor);
if (android.os.Build.VERSION.SDK_INT >= 23) {
new AudioWaveForm(getContext(), audio).getWaveForm(
data -> {
if (duration != null) {
durationMillis = data.getDuration(TimeUnit.MILLISECONDS);
updateProgress(0, 0);
duration.setVisibility(VISIBLE);
}
waveFormView.setWaveData(data.getWaveForm());
},
() -> waveFormView.setWaveMode(false));
} else {
waveFormView.setWaveMode(false);
if (duration != null) {
duration.setVisibility(GONE);
}
}
}
}
public void cleanup() {
@@ -210,10 +244,9 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
}
private void updateProgress(float progress, long millis) {
if (timestamp != null) {
timestamp.setText(String.format(Locale.getDefault(), "%02d:%02d",
TimeUnit.MILLISECONDS.toMinutes(millis),
TimeUnit.MILLISECONDS.toSeconds(millis)));
if (duration != null && durationMillis > 0) {
long remainingSecs = TimeUnit.MILLISECONDS.toSeconds(durationMillis - millis);
duration.setText(getResources().getString(R.string.AudioView_duration, remainingSecs / 60, remainingSecs % 60));
}
if (smallView) {
@@ -221,7 +254,7 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
}
}
public void setTint(int foregroundTint, int backgroundTint) {
public void setTint(int foregroundTint) {
post(()-> this.playPauseButton.addValueCallback(new KeyPath("**"),
LottieProperty.COLOR_FILTER,
new LottieValueCallback<>(new SimpleColorFilter(foregroundTint))));
@@ -229,8 +262,8 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
this.downloadButton.setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
this.circleProgress.setBarColor(foregroundTint);
if (this.timestamp != null) {
this.timestamp.setTextColor(foregroundTint);
if (this.duration != null) {
this.duration.setTextColor(foregroundTint);
}
this.seekBar.getProgressDrawable().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
this.seekBar.getThumb().setColorFilter(foregroundTint, PorterDuff.Mode.SRC_IN);
@@ -336,7 +369,12 @@ public final class AudioView extends FrameLayout implements AudioSlidePlayer.Lis
private boolean wasPlaying;
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {}
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser && durationMillis > 0) {
float progressFloat = progress / (float) seekBar.getMax();
updateProgress(progressFloat, (long) (durationMillis * progressFloat));
}
}
@Override
public synchronized void onStartTrackingTouch(SeekBar seekBar) {

View File

@@ -21,8 +21,9 @@ import android.widget.LinearLayout;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.views.DarkOverflowToolbar;
public class ContactFilterToolbar extends Toolbar {
public class ContactFilterToolbar extends DarkOverflowToolbar {
private OnFilterChangedListener listener;
private EditText searchText;

View File

@@ -0,0 +1,156 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.animation.Interpolator;
import android.view.animation.OvershootInterpolator;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.appcompat.widget.AppCompatSeekBar;
import org.thoughtcrime.securesms.R;
import java.util.Arrays;
public final class WaveFormSeekBarView extends AppCompatSeekBar {
private static final int ANIM_DURATION = 450;
private static final int ANIM_BAR_OFF_SET_DURATION = 12;
private final Interpolator overshoot = new OvershootInterpolator();
private final Paint paint = new Paint();
private float[] data = new float[0];
private long dataSetTime;
private Drawable progressDrawable;
private boolean waveMode;
@ColorInt private int playedBarColor = 0xffffffff;
@ColorInt private int unplayedBarColor = 0x7fffffff;
@Px private int barWidth;
public WaveFormSeekBarView(Context context) {
super(context);
init();
}
public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init();
}
public WaveFormSeekBarView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
setWillNotDraw(false);
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setAntiAlias(true);
progressDrawable = super.getProgressDrawable();
if (isInEditMode()) {
setWaveData(sinusoidalExampleData());
dataSetTime = 0;
}
barWidth = getResources().getDimensionPixelSize(R.dimen.wave_form_bar_width);
}
public void setColors(@ColorInt int playedBarColor, @ColorInt int unplayedBarColor) {
this.playedBarColor = playedBarColor;
this.unplayedBarColor = unplayedBarColor;
invalidate();
}
@Override
public void setProgressDrawable(Drawable progressDrawable) {
this.progressDrawable = progressDrawable;
if (!waveMode) {
super.setProgressDrawable(progressDrawable);
}
}
@Override
public Drawable getProgressDrawable() {
return progressDrawable;
}
public void setWaveData(@NonNull float[] data) {
if (!Arrays.equals(data, this.data)) {
this.data = data;
this.dataSetTime = System.currentTimeMillis();
}
setWaveMode(data.length > 0);
}
public void setWaveMode(boolean waveMode) {
this.waveMode = waveMode;
super.setProgressDrawable(this.waveMode ? null : progressDrawable);
invalidate();
}
@Override
protected void onDraw(Canvas canvas) {
if (waveMode) {
drawWave(canvas);
}
super.onDraw(canvas);
}
private void drawWave(Canvas canvas) {
paint.setStrokeWidth(barWidth);
int usableHeight = getHeight() - getPaddingTop() - getPaddingBottom();
int usableWidth = getWidth() - getPaddingLeft() - getPaddingRight();
float midpoint = usableHeight / 2f;
float maxHeight = usableHeight / 2f - barWidth;
float barGap = (usableWidth - data.length * barWidth) / (float) (data.length - 1);
boolean hasMoreFrames = false;
canvas.save();
canvas.translate(getPaddingLeft(), getPaddingTop());
for (int bar = 0; bar < data.length; bar++) {
float x = bar * (barWidth + barGap) + barWidth / 2f;
float y = data[bar] * maxHeight;
float progress = x / usableWidth;
paint.setColor(progress * getMax() < getProgress() ? playedBarColor : unplayedBarColor);
long time = System.currentTimeMillis() - bar * ANIM_BAR_OFF_SET_DURATION - dataSetTime;
float timeX = Math.max(0, Math.min(1, time / (float) ANIM_DURATION));
float interpolatedTime = overshoot.getInterpolation(timeX);
float interpolatedY = y * interpolatedTime;
canvas.drawLine(x, midpoint - interpolatedY, x, midpoint + interpolatedY, paint);
if (time < ANIM_DURATION) {
hasMoreFrames = true;
}
}
canvas.restore();
if (hasMoreFrames) {
invalidate();
}
}
private static float[] sinusoidalExampleData() {
float[] data = new float[21];
for (int i = 0; i < data.length; i++) {
data[i] = (float) Math.sin(i / (float) (data.length - 1) * 2 * Math.PI);
}
return data;
}
}

View File

@@ -29,6 +29,8 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
{
private static final KeyEvent DELETE_KEY_EVENT = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL);
private static final String RECENT_STORAGE_KEY = "pref_recent_emoji2";
private final Context context;
private final List<EmojiPageModel> models;
private final RecentEmojiPageModel recentModel;
@@ -41,7 +43,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
this.context = context;
this.emojiEventListener = emojiEventListener;
this.models = new LinkedList<>();
this.recentModel = new RecentEmojiPageModel(context);
this.recentModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
this.emojiPagerAdapter = new EmojiPagerAdapter(context, models, new EmojiEventListener() {
@Override
public void onEmojiSelected(String emoji) {

View File

@@ -2,4 +2,10 @@ package org.thoughtcrime.securesms.components.emoji;
public final class EmojiStrings {
public static final String BUST_IN_SILHOUETTE = "\uD83D\uDC64";
public static final String PHOTO = "\uD83D\uDCF7";
public static final String VIDEO = "\uD83C\uDFA5";
public static final String GIF = "\uD83C\uDFA1";
public static final String AUDIO = "\uD83C\uDFA4";
public static final String FILE = "\uD83D\uDCCE";
public static final String STICKER = "\u2B50";
}

View File

@@ -22,20 +22,21 @@ import java.util.LinkedHashSet;
import java.util.List;
public class RecentEmojiPageModel implements EmojiPageModel {
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final String EMOJI_LRU_PREFERENCE = "pref_recent_emoji2";
private static final int EMOJI_LRU_SIZE = 50;
private static final String TAG = RecentEmojiPageModel.class.getSimpleName();
private static final int EMOJI_LRU_SIZE = 50;
private final SharedPreferences prefs;
private final String preferenceName;
private final LinkedHashSet<String> recentlyUsed;
public RecentEmojiPageModel(Context context) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.recentlyUsed = getPersistedCache();
public RecentEmojiPageModel(Context context, @NonNull String preferenceName) {
this.prefs = PreferenceManager.getDefaultSharedPreferences(context);
this.preferenceName = preferenceName;
this.recentlyUsed = getPersistedCache();
}
private LinkedHashSet<String> getPersistedCache() {
String serialized = prefs.getString(EMOJI_LRU_PREFERENCE, "[]");
String serialized = prefs.getString(preferenceName, "[]");
try {
CollectionType collectionType = TypeFactory.defaultInstance()
.constructCollectionType(LinkedHashSet.class, String.class);
@@ -90,7 +91,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
try {
String serialized = JsonUtils.toJson(latestRecentlyUsed);
prefs.edit()
.putString(EMOJI_LRU_PREFERENCE, serialized)
.putString(preferenceName, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);

View File

@@ -1,10 +1,16 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.RadioButton;
import android.widget.Switch;
import android.widget.TextView;
import androidx.annotation.CallSuper;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import androidx.recyclerview.widget.RecyclerView;
@@ -12,26 +18,35 @@ import org.thoughtcrime.securesms.R;
import java.util.List;
final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.AudioOutputViewHolder> {
final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.ViewHolder> {
private final Consumer<WebRtcAudioOutput> consumer;
private final List<WebRtcAudioOutput> audioOutputs;
private final OnAudioOutputChangedListener onAudioOutputChangedListener;
private final List<WebRtcAudioOutput> audioOutputs;
AudioOutputAdapter(@NonNull Consumer<WebRtcAudioOutput> consumer, @NonNull List<WebRtcAudioOutput> audioOutputs) {
this.audioOutputs = audioOutputs;
this.consumer = consumer;
private WebRtcAudioOutput selected;
AudioOutputAdapter(@NonNull OnAudioOutputChangedListener onAudioOutputChangedListener,
@NonNull List<WebRtcAudioOutput> audioOutputs) {
this.audioOutputs = audioOutputs;
this.onAudioOutputChangedListener = onAudioOutputChangedListener;
}
public void setSelectedOutput(@NonNull WebRtcAudioOutput selected) {
this.selected = selected;
notifyDataSetChanged();
}
@Override
public @NonNull AudioOutputViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new AudioOutputViewHolder((TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_item, parent, false), consumer);
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_radio_item, parent, false);
return new ViewHolder(view, this::handlePositionSelected);
}
@Override
public void onBindViewHolder(@NonNull AudioOutputViewHolder holder, int position) {
WebRtcAudioOutput audioOutput = audioOutputs.get(position);
holder.view.setText(audioOutput.getLabelRes());
holder.view.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0);
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(audioOutputs.get(position), selected);
}
@Override
@@ -39,21 +54,46 @@ final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.A
return audioOutputs.size();
}
final static class AudioOutputViewHolder extends RecyclerView.ViewHolder {
private void handlePositionSelected(int position) {
WebRtcAudioOutput mode = audioOutputs.get(position);
private final TextView view;
AudioOutputViewHolder(@NonNull TextView itemView, @NonNull Consumer<WebRtcAudioOutput> consumer) {
super(itemView);
view = itemView;
itemView.setOnClickListener(v -> {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
consumer.accept(WebRtcAudioOutput.values()[getAdapterPosition()]);
}
});
if (mode != selected) {
setSelectedOutput(mode);
onAudioOutputChangedListener.audioOutputChanged(selected);
}
}
static class ViewHolder extends RecyclerView.ViewHolder implements CompoundButton.OnCheckedChangeListener {
private final TextView textView;
private final RadioButton radioButton;
private final Consumer<Integer> onPressed;
public ViewHolder(@NonNull View itemView, @NonNull Consumer<Integer> onPressed) {
super(itemView);
this.textView = itemView.findViewById(R.id.text);
this.radioButton = itemView.findViewById(R.id.radio);
this.onPressed = onPressed;
}
@CallSuper
void bind(@NonNull WebRtcAudioOutput audioOutput, @Nullable WebRtcAudioOutput selected) {
textView.setText(audioOutput.getLabelRes());
textView.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0);
radioButton.setOnCheckedChangeListener(null);
radioButton.setChecked(audioOutput == selected);
radioButton.setOnCheckedChangeListener(this);
}
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
int adapterPosition = getAdapterPosition();
if (adapterPosition != RecyclerView.NO_POSITION) {
onPressed.accept(adapterPosition);
}
}
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.components.webrtc;
public interface OnAudioOutputChangedListener {
void audioOutputChanged(WebRtcAudioOutput audioOutput);
}

View File

@@ -6,9 +6,9 @@ import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
public enum WebRtcAudioOutput {
HANDSET(R.string.WebRtcAudioOutputToggle__phone, R.drawable.ic_phone_right_black_28),
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.ic_speaker_solid_black_28),
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_solid_black_28);
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_24),
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.ic_speaker_solid_24),
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_solid_24);
private final @StringRes int labelRes;
private final @DrawableRes int iconRes;

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
@@ -14,6 +15,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -21,37 +23,48 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
private static final String STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index";
private static final String STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled";
private static final String STATE_HANDSET_ENABLED = "audio.output.toggle.state.handset.enabled";
private static final String STATE_PARENT = "audio.output.toggle.state.parent";
private static final int[] OUTPUT_HANDSET = { R.attr.state_handset };
private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker };
private static final int[] OUTPUT_HEADSET = { R.attr.state_headset };
private static final int[][] OUTPUT_ENUM = { OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET };
private static final List<WebRtcAudioOutput> OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET);
private static final WebRtcAudioOutput OUTPUT_FALLBACK = WebRtcAudioOutput.HANDSET;
private static final int[] SPEAKER_OFF = { R.attr.state_speaker_off };
private static final int[] SPEAKER_ON = { R.attr.state_speaker_on };
private static final int[] OUTPUT_HANDSET = { R.attr.state_handset_selected };
private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker_selected };
private static final int[] OUTPUT_HEADSET = { R.attr.state_headset_selected };
private static final int[][] OUTPUT_ENUM = { SPEAKER_OFF, SPEAKER_ON, OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET };
private static final List<WebRtcAudioOutput> OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET);
private boolean isHeadsetAvailable;
private boolean isHandsetAvailable;
private int outputIndex;
private OnAudioOutputChangedListener audioOutputChangedListener;
private AlertDialog picker;
private DialogInterface picker;
public WebRtcAudioOutputToggleButton(Context context) {
public WebRtcAudioOutputToggleButton(@NonNull Context context) {
this(context, null);
}
public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs) {
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs, int defStyleAttr) {
public WebRtcAudioOutputToggleButton(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setOnClickListener((v) -> {
if (isHeadsetAvailable) showPicker();
else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_ENUM.length));
List<WebRtcAudioOutput> availableModes = buildOutputModeList(isHeadsetAvailable, isHandsetAvailable);
if (availableModes.size() > 2 || !isHandsetAvailable) showPicker(availableModes);
else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_MODES.size()), true);
});
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
hidePicker();
}
@Override
public int[] onCreateDrawableState(int extraSpace) {
final int[] extra = OUTPUT_ENUM[outputIndex];
@@ -65,18 +78,21 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
throw new UnsupportedOperationException("This View does not support custom click listeners.");
}
public void setIsHeadsetAvailable(boolean isHeadsetAvailable) {
public void setControlAvailability(boolean isHandsetAvailable, boolean isHeadsetAvailable) {
this.isHandsetAvailable = isHandsetAvailable;
this.isHeadsetAvailable = isHeadsetAvailable;
setAudioOutput(OUTPUT_MODES.get(outputIndex));
}
public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput) {
public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput, boolean notifyListener) {
int oldIndex = outputIndex;
outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.indexOf(audioOutput), isHeadsetAvailable);
outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.lastIndexOf(audioOutput));
if (oldIndex != outputIndex) {
refreshDrawableState();
notifyListener();
if (notifyListener) {
notifyListener();
}
}
}
@@ -84,23 +100,26 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
this.audioOutputChangedListener = listener;
}
private void showPicker() {
RecyclerView rv = new RecyclerView(getContext());
private void showPicker(@NonNull List<WebRtcAudioOutput> availableModes) {
RecyclerView rv = new RecyclerView(getContext());
AudioOutputAdapter adapter = new AudioOutputAdapter(audioOutput -> {
setAudioOutput(audioOutput, true);
hidePicker();
},
availableModes);
adapter.setSelectedOutput(OUTPUT_MODES.get(outputIndex));
rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
rv.setAdapter(new AudioOutputAdapter(this::setAudioOutputViaDialog, OUTPUT_MODES));
rv.setAdapter(adapter);
picker = new AlertDialog.Builder(getContext())
.setTitle(R.string.WebRtcAudioOutputToggle__audio_output)
.setView(rv)
.setCancelable(true)
.show();
}
private void hidePicker() {
if (picker != null) {
picker.dismiss();
picker = null;
}
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable parentState = super.onSaveInstanceState();
@@ -109,6 +128,7 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
bundle.putParcelable(STATE_PARENT, parentState);
bundle.putInt(STATE_OUTPUT_INDEX, outputIndex);
bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable);
bundle.putBoolean(STATE_HANDSET_ENABLED, isHandsetAvailable);
return bundle;
}
@@ -118,8 +138,11 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
Bundle savedState = (Bundle) state;
isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED);
isHandsetAvailable = savedState.getBoolean(STATE_HANDSET_ENABLED);
setAudioOutput(OUTPUT_MODES.get(
resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX), isHeadsetAvailable))
resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX))),
false
);
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
@@ -128,36 +151,60 @@ public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
}
}
private void hidePicker() {
if (picker != null) {
picker.dismiss();
picker = null;
}
}
private void notifyListener() {
if (audioOutputChangedListener == null) return;
audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex));
}
private void setAudioOutputViaDialog(@NonNull WebRtcAudioOutput audioOutput) {
setAudioOutput(audioOutput);
hidePicker();
}
private static List<WebRtcAudioOutput> buildOutputModeList(boolean isHeadsetAvailable, boolean isHandsetAvailable) {
List<WebRtcAudioOutput> modes = new ArrayList(3);
private static int resolveAudioOutputIndex(int desiredAudioOutputIndex, boolean isHeadsetAvailable) {
modes.add(WebRtcAudioOutput.SPEAKER);
if (isHeadsetAvailable) {
modes.add(WebRtcAudioOutput.HEADSET);
}
if (isHandsetAvailable) {
modes.add(WebRtcAudioOutput.HANDSET);
}
return modes;
};
private int resolveAudioOutputIndex(int desiredAudioOutputIndex) {
if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) {
throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex);
}
if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable)) {
return OUTPUT_MODES.indexOf(OUTPUT_FALLBACK);
if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable, isHandsetAvailable)) {
if (!isHandsetAvailable) {
return OUTPUT_MODES.lastIndexOf(WebRtcAudioOutput.SPEAKER);
} else {
return OUTPUT_MODES.indexOf(WebRtcAudioOutput.HANDSET);
}
}
if (!isHeadsetAvailable) {
return desiredAudioOutputIndex % 2;
}
return desiredAudioOutputIndex;
}
private static boolean isIllegalAudioOutputIndex(int desiredFlashIndex) {
return desiredFlashIndex < 0 || desiredFlashIndex > OUTPUT_ENUM.length;
private static boolean isIllegalAudioOutputIndex(int desiredAudioOutputIndex) {
return desiredAudioOutputIndex < 0 || desiredAudioOutputIndex > OUTPUT_MODES.size();
}
private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable) {
return OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable;
}
public interface OnAudioOutputChangedListener {
void audioOutputChanged(WebRtcAudioOutput audioOutput);
private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable, boolean isHandsetAvailable) {
return (OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable) ||
(OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HANDSET && !isHandsetAvailable);
}
}

View File

@@ -21,7 +21,6 @@ import androidx.transition.Transition;
import androidx.transition.TransitionManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.collect.Sets;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
@@ -29,7 +28,6 @@ import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -40,19 +38,20 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
public class WebRtcCallView extends FrameLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
public static final int FADE_OUT_DELAY = 5000;
private TextureViewRenderer localRenderer;
private WebRtcAudioOutputToggleButton speakerToggle;
private WebRtcAudioOutputToggleButton audioToggle;
private AccessibleToggleButton videoToggle;
private AccessibleToggleButton micToggle;
private ViewGroup largeLocalRenderContainer;
@@ -68,18 +67,21 @@ public class WebRtcCallView extends FrameLayout {
private RecipientId recipientId;
private CameraState.Direction cameraDirection;
private ImageView answer;
private View cameraDirectionToggle;
private ImageView cameraDirectionToggle;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView hangup;
private View answerWithAudio;
private View answerWithAudioLabel;
private View ongoingFooterGradient;
private Set<View> ongoingAudioCallViews;
private Set<View> ongoingVideoCallViews;
private Set<View> incomingAudioCallViews;
private Set<View> incomingVideoCallViews;
private Set<View> currentVisibleViewSet = Collections.emptySet();
private final Set<View> incomingCallViews = new HashSet<>();
private final Set<View> topViews = new HashSet<>();
private final Set<View> visibleViewSet = new HashSet<>();
private final Set<View> adjustableMarginsSet = new HashSet<>();
private WebRtcControls controls = WebRtcControls.NONE;
private final Runnable fadeOutRunnable = () -> { if (isAttachedToWindow() && shouldFadeControls(controls)) fadeOutControls(); };
private final Runnable fadeOutRunnable = () -> {
if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); };
public WebRtcCallView(@NonNull Context context) {
this(context, null);
@@ -96,9 +98,9 @@ public class WebRtcCallView extends FrameLayout {
protected void onFinishInflate() {
super.onFinishInflate();
speakerToggle = findViewById(R.id.call_screen_speaker_toggle);
audioToggle = findViewById(R.id.call_screen_speaker_toggle);
videoToggle = findViewById(R.id.call_screen_video_toggle);
micToggle = findViewById(R.id.call_screen_mic_toggle);
micToggle = findViewById(R.id.call_screen_audio_mic_toggle);
localRenderPipFrame = findViewById(R.id.call_screen_pip);
largeLocalRenderContainer = findViewById(R.id.call_screen_large_local_renderer_holder);
smallLocalRenderContainer = findViewById(R.id.call_screen_small_local_renderer_holder);
@@ -110,31 +112,34 @@ public class WebRtcCallView extends FrameLayout {
avatarCard = findViewById(R.id.call_screen_recipient_avatar_call_card);
answer = findViewById(R.id.call_screen_answer_call);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
hangup = findViewById(R.id.call_screen_end_call);
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
ongoingFooterGradient = findViewById(R.id.call_screen_ongoing_footer_gradient);
View topGradient = findViewById(R.id.call_screen_header_gradient);
View hangup = findViewById(R.id.call_screen_end_call);
View downCaret = findViewById(R.id.call_screen_down_arrow);
View decline = findViewById(R.id.call_screen_decline_call);
View answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
View answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
View topGradient = findViewById(R.id.call_screen_header_gradient);
View downCaret = findViewById(R.id.call_screen_down_arrow);
View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
View declineLabel = findViewById(R.id.call_screen_decline_call_label);
View incomingFooterGradient = findViewById(R.id.call_screen_incoming_footer_gradient);
Set<View> topAreaViews = Sets.newHashSet(status, topGradient, recipientName);
topViews.add(status);
topViews.add(topGradient);
topViews.add(recipientName);
incomingAudioCallViews = Sets.newHashSet(decline, declineLabel, answer, answerLabel);
incomingAudioCallViews.addAll(topAreaViews);
incomingCallViews.add(answer);
incomingCallViews.add(answerLabel);
incomingCallViews.add(decline);
incomingCallViews.add(declineLabel);
incomingCallViews.add(incomingFooterGradient);
incomingVideoCallViews = Sets.newHashSet(decline, declineLabel, answer, answerLabel, answerWithAudio, answerWithAudioLabel);
incomingVideoCallViews.addAll(topAreaViews);
adjustableMarginsSet.add(micToggle);
adjustableMarginsSet.add(cameraDirectionToggle);
adjustableMarginsSet.add(videoToggle);
adjustableMarginsSet.add(audioToggle);
ongoingAudioCallViews = Sets.newHashSet(micToggle, speakerToggle, videoToggle, hangup);
ongoingAudioCallViews.addAll(topAreaViews);
ongoingVideoCallViews = Sets.newHashSet();
ongoingVideoCallViews.addAll(ongoingAudioCallViews);
speakerToggle.setOnAudioOutputChangedListener(outputMode -> {
audioToggle.setOnAudioOutputChangedListener(outputMode -> {
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
});
@@ -172,7 +177,7 @@ public class WebRtcCallView extends FrameLayout {
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (shouldFadeControls(controls)) {
if (controls.isFadeOutEnabled()) {
scheduleFadeOut();
}
}
@@ -183,10 +188,6 @@ public class WebRtcCallView extends FrameLayout {
cancelFadeOut();
}
public void showCameraToggleButton(boolean shouldShowCameraToggleButton) {
cameraDirectionToggle.setVisibility(shouldShowCameraToggleButton ? VISIBLE : GONE);
}
public void setControlsListener(@Nullable ControlsListener controlsListener) {
this.controlsListener = controlsListener;
}
@@ -195,14 +196,6 @@ public class WebRtcCallView extends FrameLayout {
micToggle.setChecked(isMicEnabled, false);
}
public void setBluetoothEnabled(boolean isBluetoothEnabled) {
speakerToggle.setIsHeadsetAvailable(isBluetoothEnabled);
}
public void setAudioOutput(WebRtcAudioOutput output) {
speakerToggle.setAudioOutput(output);
}
public void setRemoteVideoEnabled(boolean isRemoteVideoEnabled) {
if (isRemoteVideoEnabled) {
remoteRenderContainer.setVisibility(View.VISIBLE);
@@ -239,14 +232,12 @@ public class WebRtcCallView extends FrameLayout {
case GONE:
localRenderPipFrame.setVisibility(View.GONE);
largeLocalRenderContainer.setVisibility(View.GONE);
cameraDirectionToggle.animate().setDuration(0).alpha(0f);
setRenderer(largeLocalRenderContainer, null);
setRenderer(smallLocalRenderContainer, null);
break;
case LARGE:
localRenderPipFrame.setVisibility(View.GONE);
largeLocalRenderContainer.setVisibility(View.VISIBLE);
cameraDirectionToggle.animate().setDuration(0).alpha(0f);
if (largeLocalRenderContainer.getChildCount() == 0) {
setRenderer(largeLocalRenderContainer, localRenderer);
}
@@ -254,9 +245,6 @@ public class WebRtcCallView extends FrameLayout {
case SMALL:
localRenderPipFrame.setVisibility(View.VISIBLE);
largeLocalRenderContainer.setVisibility(View.GONE);
cameraDirectionToggle.animate()
.setDuration(450)
.alpha(1f);
if (smallLocalRenderContainer.getChildCount() == 0) {
setRenderer(smallLocalRenderContainer, localRenderer);
@@ -315,54 +303,71 @@ public class WebRtcCallView extends FrameLayout {
}
public void setWebRtcControls(WebRtcControls webRtcControls) {
Set<View> lastVisibleSet = currentVisibleViewSet;
Set<View> lastVisibleSet = new HashSet<>(visibleViewSet);
Log.d(TAG, "Setting Controls: " + controls.name() + " -> " + webRtcControls.name());
visibleViewSet.clear();
visibleViewSet.addAll(topViews);
switch (webRtcControls) {
case NONE:
currentVisibleViewSet = Collections.emptySet();
cancelFadeOut();
break;
case INCOMING_VIDEO:
currentVisibleViewSet = incomingVideoCallViews;
status.setText(R.string.WebRtcCallView__signal_video_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
cancelFadeOut();
break;
case INCOMING_AUDIO:
currentVisibleViewSet = incomingAudioCallViews;
status.setText(R.string.WebRtcCallView__signal_voice_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
cancelFadeOut();
break;
case ONGOING_LOCAL_AUDIO_REMOTE_AUDIO:
currentVisibleViewSet = ongoingAudioCallViews;
cancelFadeOut();
break;
case ONGOING_LOCAL_AUDIO_REMOTE_VIDEO:
currentVisibleViewSet = ongoingAudioCallViews;
if (!shouldFadeControls(controls)) {
scheduleFadeOut();
}
break;
case ONGOING_LOCAL_VIDEO_REMOTE_AUDIO:
currentVisibleViewSet = ongoingVideoCallViews;
cancelFadeOut();
break;
case ONGOING_LOCAL_VIDEO_REMOTE_VIDEO:
currentVisibleViewSet = ongoingVideoCallViews;
if (!shouldFadeControls(controls)) {
scheduleFadeOut();
}
break;
if (webRtcControls.displayIncomingCallButtons()) {
visibleViewSet.addAll(incomingCallViews);
status.setText(R.string.WebRtcCallView__signal_voice_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
}
if (webRtcControls.displayAnswerWithAudio()) {
visibleViewSet.add(answerWithAudio);
visibleViewSet.add(answerWithAudioLabel);
status.setText(R.string.WebRtcCallView__signal_video_call);
answer.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
}
if (webRtcControls.displayAudioToggle()) {
visibleViewSet.add(audioToggle);
audioToggle.setControlAvailability(webRtcControls.enableHandsetInAudioToggle(),
webRtcControls.enableHeadsetInAudioToggle());
audioToggle.setAudioOutput(webRtcControls.getAudioOutput(), false);
}
if (webRtcControls.displayCameraToggle()) {
visibleViewSet.add(cameraDirectionToggle);
}
if (webRtcControls.displayEndCall()) {
visibleViewSet.add(hangup);
visibleViewSet.add(ongoingFooterGradient);
}
if (webRtcControls.displayMuteAudio()) {
visibleViewSet.add(micToggle);
}
if (webRtcControls.displayVideoToggle()) {
visibleViewSet.add(videoToggle);
}
if (webRtcControls.displaySmallOngoingCallButtons()) {
updateButtonStateForSmallButtons();
} else if (webRtcControls.displayLargeOngoingCallButtons()) {
updateButtonStateForLargeButtons();
}
if (webRtcControls.isFadeOutEnabled()) {
if (!controls.isFadeOutEnabled()) {
scheduleFadeOut();
}
} else {
cancelFadeOut();
}
controls = webRtcControls;
if (!currentVisibleViewSet.equals(lastVisibleSet) || !shouldFadeControls(controls)) {
fadeInNewUiState(lastVisibleSet);
post(() -> pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop()));
if (!visibleViewSet.equals(lastVisibleSet) || !controls.isFadeOutEnabled()) {
fadeInNewUiState(lastVisibleSet, webRtcControls.displaySmallOngoingCallButtons());
post(() -> pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), videoToggle.getTop()));
}
}
@@ -371,7 +376,7 @@ public class WebRtcCallView extends FrameLayout {
}
private void toggleControls() {
if (shouldFadeControls(controls) && status.getVisibility() == VISIBLE) {
if (controls.isFadeOutEnabled() && status.getVisibility() == VISIBLE) {
fadeOutControls();
} else {
fadeInControls();
@@ -386,7 +391,7 @@ public class WebRtcCallView extends FrameLayout {
private void fadeInControls() {
fadeControls(ConstraintSet.VISIBLE);
pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop());
pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), videoToggle.getTop());
scheduleFadeOut();
}
@@ -399,14 +404,14 @@ public class WebRtcCallView extends FrameLayout {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : currentVisibleViewSet) {
for (View view : visibleViewSet) {
constraintSet.setVisibility(view.getId(), visibility);
}
constraintSet.applyTo(parent);
}
private void fadeInNewUiState(@NonNull Set<View> previouslyVisibleViewSet) {
private void fadeInNewUiState(@NonNull Set<View> previouslyVisibleViewSet, boolean useSmallMargins) {
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
TransitionManager.beginDelayedTransition(parent, transition);
@@ -414,12 +419,19 @@ public class WebRtcCallView extends FrameLayout {
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
for (View view : SetUtil.difference(previouslyVisibleViewSet, currentVisibleViewSet)) {
for (View view : SetUtil.difference(previouslyVisibleViewSet, visibleViewSet)) {
constraintSet.setVisibility(view.getId(), ConstraintSet.GONE);
}
for (View view : currentVisibleViewSet) {
for (View view : visibleViewSet) {
constraintSet.setVisibility(view.getId(), ConstraintSet.VISIBLE);
if (adjustableMarginsSet.contains(view)) {
constraintSet.setMargin(view.getId(),
ConstraintSet.END,
ViewUtil.dpToPx(useSmallMargins ? SMALL_ONGOING_CALL_BUTTON_MARGIN_DP
: LARGE_ONGOING_CALL_BUTTON_MARGIN_DP));
}
}
constraintSet.applyTo(parent);
@@ -477,8 +489,20 @@ public class WebRtcCallView extends FrameLayout {
this.avatarCard.setBackgroundColor(recipient.getColor().toActionBarColor(getContext()));
}
private static boolean shouldFadeControls(@NonNull WebRtcControls controls) {
return controls == WebRtcControls.ONGOING_LOCAL_AUDIO_REMOTE_VIDEO || controls == WebRtcControls.ONGOING_LOCAL_VIDEO_REMOTE_VIDEO;
private void updateButtonStateForLargeButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup);
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);
}
private void updateButtonStateForSmallButtons() {
cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle_small);
hangup.setImageResource(R.drawable.webrtc_call_screen_hangup_small);
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);
}
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {

View File

@@ -20,14 +20,11 @@ import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> remoteVideoEnabled = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcAudioOutput> audioOutput = new MutableLiveData<>();
private final MutableLiveData<Boolean> bluetoothEnabled = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<WebRtcLocalRenderState> localRenderState = new MutableLiveData<>(WebRtcLocalRenderState.GONE);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> localVideoEnabled = new MutableLiveData<>(false);
private final MutableLiveData<CameraState.Direction> cameraDirection = new MutableLiveData<>(CameraState.Direction.FRONT);
private final MutableLiveData<Boolean> hasMultipleCameras = new MutableLiveData<>(false);
private final LiveData<Boolean> shouldDisplayLocal = LiveDataUtil.combineLatest(isInPipMode, localVideoEnabled, (a, b) -> !a && b);
private final LiveData<WebRtcLocalRenderState> realLocalRenderState = LiveDataUtil.combineLatest(shouldDisplayLocal, localRenderState, this::getRealLocalRenderState);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
@@ -46,22 +43,10 @@ public class WebRtcCallViewModel extends ViewModel {
private final WebRtcCallRepository repository = new WebRtcCallRepository();
public WebRtcCallViewModel() {
audioOutput.setValue(repository.getAudioOutput());
}
public LiveData<Boolean> getRemoteVideoEnabled() {
return Transformations.distinctUntilChanged(remoteVideoEnabled);
}
public LiveData<WebRtcAudioOutput> getAudioOutput() {
return Transformations.distinctUntilChanged(audioOutput);
}
public LiveData<Boolean> getBluetoothEnabled() {
return Transformations.distinctUntilChanged(bluetoothEnabled);
}
public LiveData<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
@@ -98,10 +83,6 @@ public class WebRtcCallViewModel extends ViewModel {
return Transformations.map(ellapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
}
public LiveData<Boolean> isMoreThanOneCameraAvailable() {
return hasMultipleCameras;
}
public boolean isAnswerWithVideoAvailable() {
return answerWithVideoAvailable;
}
@@ -118,21 +99,21 @@ public class WebRtcCallViewModel extends ViewModel {
@MainThread
public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel) {
remoteVideoEnabled.setValue(webRtcViewModel.isRemoteVideoEnabled());
bluetoothEnabled.setValue(webRtcViewModel.isBluetoothAvailable());
audioOutput.setValue(repository.getAudioOutput());
microphoneEnabled.setValue(webRtcViewModel.isMicrophoneEnabled());
if (isValidCameraDirectionForUi(webRtcViewModel.getLocalCameraState().getActiveDirection())) {
cameraDirection.setValue(webRtcViewModel.getLocalCameraState().getActiveDirection());
}
hasMultipleCameras.setValue(webRtcViewModel.getLocalCameraState().getCameraCount() > 0);
localVideoEnabled.setValue(webRtcViewModel.getLocalCameraState().isEnabled());
updateLocalRenderState(webRtcViewModel.getState());
updateWebRtcControls(webRtcViewModel.getState(),
webRtcViewModel.getLocalCameraState().isEnabled(),
webRtcViewModel.isRemoteVideoEnabled(),
webRtcViewModel.isRemoteVideoOffer());
webRtcViewModel.isRemoteVideoOffer(),
webRtcViewModel.getLocalCameraState().getCameraCount() > 1,
webRtcViewModel.isBluetoothAvailable(),
repository.getAudioOutput());
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = webRtcViewModel.getCallConnectedTime();
@@ -167,23 +148,32 @@ public class WebRtcCallViewModel extends ViewModel {
}
}
private void updateWebRtcControls(WebRtcViewModel.State state, boolean isLocalVideoEnabled, boolean isRemoteVideoEnabled, boolean isRemoteVideoOffer) {
private void updateWebRtcControls(WebRtcViewModel.State state,
boolean isLocalVideoEnabled,
boolean isRemoteVideoEnabled,
boolean isRemoteVideoOffer,
boolean isMoreThanOneCameraAvailable,
boolean isBluetoothAvailable,
WebRtcAudioOutput audioOutput)
{
final WebRtcControls.CallState callState;
switch (state) {
case CALL_INCOMING:
webRtcControls.setValue(isRemoteVideoOffer ? WebRtcControls.INCOMING_VIDEO : WebRtcControls.INCOMING_AUDIO);
callState = WebRtcControls.CallState.INCOMING;
answerWithVideoAvailable = isRemoteVideoOffer;
break;
default:
if (isLocalVideoEnabled && isRemoteVideoEnabled) {
webRtcControls.setValue(WebRtcControls.ONGOING_LOCAL_VIDEO_REMOTE_VIDEO);
} else if (isLocalVideoEnabled) {
webRtcControls.setValue(WebRtcControls.ONGOING_LOCAL_VIDEO_REMOTE_AUDIO);
} else if (isRemoteVideoEnabled) {
webRtcControls.setValue(WebRtcControls.ONGOING_LOCAL_AUDIO_REMOTE_VIDEO);
} else {
webRtcControls.setValue(WebRtcControls.ONGOING_LOCAL_AUDIO_REMOTE_AUDIO);
}
callState = WebRtcControls.CallState.ONGOING;
}
webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled,
isRemoteVideoEnabled || isRemoteVideoOffer,
isMoreThanOneCameraAvailable,
isBluetoothAvailable,
callState,
audioOutput));
}
private @NonNull WebRtcLocalRenderState getRealLocalRenderState(boolean shouldDisplayLocalVideo, @NonNull WebRtcLocalRenderState state) {

View File

@@ -1,11 +1,100 @@
package org.thoughtcrime.securesms.components.webrtc;
public enum WebRtcControls {
NONE,
ONGOING_LOCAL_AUDIO_REMOTE_AUDIO,
ONGOING_LOCAL_AUDIO_REMOTE_VIDEO,
ONGOING_LOCAL_VIDEO_REMOTE_AUDIO,
ONGOING_LOCAL_VIDEO_REMOTE_VIDEO,
INCOMING_AUDIO,
INCOMING_VIDEO
import androidx.annotation.NonNull;
public final class WebRtcControls {
public static final WebRtcControls NONE = new WebRtcControls();
private final boolean isRemoteVideoEnabled;
private final boolean isLocalVideoEnabled;
private final boolean isMoreThanOneCameraAvailable;
private final boolean isBluetoothAvailable;
private final CallState callState;
private final WebRtcAudioOutput audioOutput;
private WebRtcControls() {
this(false, false, false, false, CallState.NONE, WebRtcAudioOutput.HANDSET);
}
WebRtcControls(boolean isLocalVideoEnabled,
boolean isRemoteVideoEnabled,
boolean isMoreThanOneCameraAvailable,
boolean isBluetoothAvailable,
@NonNull CallState callState,
@NonNull WebRtcAudioOutput audioOutput)
{
this.isLocalVideoEnabled = isLocalVideoEnabled;
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
this.isBluetoothAvailable = isBluetoothAvailable;
this.isMoreThanOneCameraAvailable = isMoreThanOneCameraAvailable;
this.callState = callState;
this.audioOutput = audioOutput;
}
boolean displayEndCall() {
return isOngoing();
}
boolean displayMuteAudio() {
return isOngoing();
}
boolean displayVideoToggle() {
return isOngoing();
}
boolean displayAudioToggle() {
return isOngoing() && (!isLocalVideoEnabled || isBluetoothAvailable);
}
boolean displayCameraToggle() {
return isOngoing() && isLocalVideoEnabled && isMoreThanOneCameraAvailable;
}
boolean displayAnswerWithAudio() {
return isIncoming() && isRemoteVideoEnabled;
}
boolean displayIncomingCallButtons() {
return isIncoming();
}
boolean enableHandsetInAudioToggle() {
return !isLocalVideoEnabled;
}
boolean enableHeadsetInAudioToggle() {
return isBluetoothAvailable;
}
boolean isFadeOutEnabled() {
return isOngoing() && isRemoteVideoEnabled;
}
boolean displaySmallOngoingCallButtons() {
return isOngoing() && displayAudioToggle() && displayCameraToggle();
}
boolean displayLargeOngoingCallButtons() {
return isOngoing() && !(displayAudioToggle() && displayCameraToggle());
}
WebRtcAudioOutput getAudioOutput() {
return audioOutput;
}
private boolean isOngoing() {
return callState == CallState.ONGOING;
}
private boolean isIncoming() {
return callState == CallState.INCOMING;
}
public enum CallState {
NONE,
INCOMING,
ONGOING
}
}

View File

@@ -44,17 +44,21 @@ public final class ContactChip extends Chip {
return contact;
}
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient) {
public void setAvatar(@NonNull GlideRequests requestManager, @Nullable Recipient recipient, @Nullable Runnable onAvatarSet) {
if (recipient != null) {
requestManager.clear(this);
Drawable fallbackContactPhotoDrawable = recipient.getFallbackContactPhotoDrawable(getContext(), false);
Drawable fallbackContactPhotoDrawable = new HalfScaleDrawable(recipient.getFallbackContactPhotoDrawable(getContext(), false));
ContactPhoto contactPhoto = recipient.getContactPhoto();
if (contactPhoto == null) {
setChipIcon(new HalfScaleDrawable(fallbackContactPhotoDrawable));
setChipIcon(fallbackContactPhotoDrawable);
if (onAvatarSet != null) {
onAvatarSet.run();
}
} else {
requestManager.load(contactPhoto)
.placeholder(fallbackContactPhotoDrawable)
.fallback(fallbackContactPhotoDrawable)
.error(fallbackContactPhotoDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
@@ -63,6 +67,9 @@ public final class ContactChip extends Chip {
@Override
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
setChipIcon(resource);
if (onAvatarSet != null) {
onAvatarSet.run();
}
}
@Override

View File

@@ -45,6 +45,7 @@ import org.thoughtcrime.securesms.util.Util;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* List adapter to display all contacts and their related information
@@ -64,11 +65,14 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
private final static int STYLE_ATTRIBUTES[] = new int[]{R.attr.contact_selection_push_user,
R.attr.contact_selection_lay_user};
public static final int PAYLOAD_SELECTION_CHANGE = 1;
private final boolean multiSelect;
private final LayoutInflater li;
private final LayoutInflater layoutInflater;
private final TypedArray drawables;
private final ItemClickListener clickListener;
private final GlideRequests glideRequests;
private final Set<RecipientId> currentContacts;
private final SelectedContactSet selectedContacts = new SelectedContactSet();
@@ -100,6 +104,7 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
public abstract void bind(@NonNull GlideRequests glideRequests, @Nullable RecipientId recipientId, int type, String name, String number, String label, int color, boolean multiSelect);
public abstract void unbind(@NonNull GlideRequests glideRequests);
public abstract void setChecked(boolean checked);
public abstract void setEnabled(boolean enabled);
}
public static class ContactViewHolder extends ViewHolder {
@@ -129,6 +134,11 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
public void setChecked(boolean checked) {
getView().setChecked(checked);
}
@Override
public void setEnabled(boolean enabled) {
getView().setEnabled(enabled);
}
}
public static class DividerViewHolder extends ViewHolder {
@@ -150,6 +160,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Override
public void setChecked(boolean checked) {}
@Override
public void setEnabled(boolean enabled) {}
}
static class HeaderViewHolder extends RecyclerView.ViewHolder {
@@ -162,19 +175,22 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@NonNull GlideRequests glideRequests,
@Nullable Cursor cursor,
@Nullable ItemClickListener clickListener,
boolean multiSelect)
boolean multiSelect,
@NonNull Set<RecipientId> currentContacts)
{
super(context, cursor);
this.li = LayoutInflater.from(context);
this.glideRequests = glideRequests;
this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES);
this.multiSelect = multiSelect;
this.clickListener = clickListener;
this.layoutInflater = LayoutInflater.from(context);
this.glideRequests = glideRequests;
this.drawables = context.obtainStyledAttributes(STYLE_ATTRIBUTES);
this.multiSelect = multiSelect;
this.clickListener = clickListener;
this.currentContacts = currentContacts;
}
@Override
public long getHeaderId(int i) {
if (!isActiveCursor()) return -1;
else if (i == -1) return -1;
int contactType = getContactType(i);
@@ -185,9 +201,9 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
@Override
public ViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
if (viewType == VIEW_TYPE_CONTACT) {
return new ContactViewHolder(li.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
return new ContactViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_item, parent, false), clickListener);
} else {
return new DividerViewHolder(li.inflate(R.layout.contact_selection_list_divider, parent, false));
return new DividerViewHolder(layoutInflater.inflate(R.layout.contact_selection_list_divider, parent, false));
}
}
@@ -208,8 +224,35 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
viewHolder.unbind(glideRequests);
viewHolder.bind(glideRequests, id, contactType, name, number, labelText, color, multiSelect);
viewHolder.setEnabled(true);
if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
if (currentContacts.contains(id)) {
viewHolder.setChecked(true);
viewHolder.setEnabled(false);
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
} else {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
}
}
@Override
protected void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor, @NonNull List<Object> payloads) {
if (!arePayloadsValid(payloads)) {
throw new AssertionError();
}
String rawId = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.ID_COLUMN));
RecipientId id = rawId != null ? RecipientId.from(rawId) : null;
int numberType = cursor.getInt(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_TYPE_COLUMN));
String number = cursor.getString(cursor.getColumnIndexOrThrow(ContactRepository.NUMBER_COLUMN));
viewHolder.setEnabled(true);
if (currentContacts.contains(id)) {
viewHolder.setChecked(true);
viewHolder.setEnabled(false);
} else if (numberType == ContactRepository.NEW_USERNAME_TYPE) {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forUsername(id, number)));
} else {
viewHolder.setChecked(selectedContacts.contains(SelectedContact.forPhone(id, number)));
@@ -225,7 +268,6 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
}
}
@Override
public HeaderViewHolder onCreateHeaderViewHolder(ViewGroup parent, int position) {
return new HeaderViewHolder(LayoutInflater.from(getContext()).inflate(R.layout.contact_selection_recyclerview_header, parent, false));
@@ -236,6 +278,11 @@ public class ContactSelectionListAdapter extends CursorRecyclerViewAdapter<ViewH
((TextView)viewHolder.itemView).setText(getSpannedHeaderString(position));
}
@Override
protected boolean arePayloadsValid(@NonNull List<Object> payloads) {
return payloads.size() == 1 && payloads.get(0).equals(PAYLOAD_SELECTION_CHANGE);
}
@Override
public void onItemViewRecycled(ViewHolder holder) {
holder.unbind(glideRequests);

View File

@@ -98,6 +98,12 @@ public class ContactSelectionListItem extends LinearLayout implements RecipientF
this.checkBox.setChecked(selected);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
this.checkBox.setEnabled(enabled);
}
public void unbind(GlideRequests glideRequests) {
if (recipient != null) {
recipient.removeForeverObserver(this);

View File

@@ -115,52 +115,73 @@ public class ContactsCursorLoader extends CursorLoader {
private List<Cursor> getUnfilteredResults() {
ArrayList<Cursor> cursorList = new ArrayList<>();
if (recents) {
Cursor recentConversations = getRecentConversationsCursor();
if (recentConversations.getCount() > 0) {
cursorList.add(getRecentsHeaderCursor());
cursorList.add(recentConversations);
cursorList.add(getContactsHeaderCursor());
}
}
cursorList.addAll(getContactsCursors());
addRecentsSection(cursorList);
addContactsSection(cursorList);
return cursorList;
}
private List<Cursor> getFilteredResults() {
ArrayList<Cursor> cursorList = new ArrayList<>();
if (groupsEnabled(mode)) {
Cursor groups = getGroupsCursor();
if (groups.getCount() > 0) {
List<Cursor> contacts = getContactsCursors();
if (!isCursorListEmpty(contacts)) {
cursorList.add(getContactsHeaderCursor());
cursorList.addAll(contacts);
cursorList.add(getGroupsHeaderCursor());
}
cursorList.add(groups);
} else {
cursorList.addAll(getContactsCursors());
}
} else {
cursorList.addAll(getContactsCursors());
addContactsSection(cursorList);
addGroupsSection(cursorList);
addNewNumberSection(cursorList);
addUsernameSearchSection(cursorList);
return cursorList;
}
private void addRecentsSection(@NonNull List<Cursor> cursorList) {
if (!recents) {
return;
}
Cursor recentConversations = getRecentConversationsCursor();
if (recentConversations.getCount() > 0) {
cursorList.add(getRecentsHeaderCursor());
cursorList.add(recentConversations);
}
}
private void addContactsSection(@NonNull List<Cursor> cursorList) {
List<Cursor> contacts = getContactsCursors();
if (!isCursorListEmpty(contacts)) {
cursorList.add(getContactsHeaderCursor());
cursorList.addAll(getContactsCursors());
}
}
private void addGroupsSection(@NonNull List<Cursor> cursorList) {
if (!groupsEnabled(mode)) {
return;
}
Cursor groups = getGroupsCursor();
if (groups.getCount() > 0) {
cursorList.add(getGroupsHeaderCursor());
cursorList.add(getGroupsCursor());
}
}
private void addNewNumberSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && NumberUtil.isVisuallyValidNumberOrEmail(filter)) {
cursorList.add(getPhoneNumberSearchHeaderCursor());
cursorList.add(getNewNumberCursor());
} else if (!FeatureFlags.usernames() && NumberUtil.isValidSmsOrEmail(filter)){
cursorList.add(getContactsHeaderCursor());
cursorList.add(getPhoneNumberSearchHeaderCursor());
cursorList.add(getNewNumberCursor());
}
}
private void addUsernameSearchSection(@NonNull List<Cursor> cursorList) {
if (FeatureFlags.usernames() && UsernameUtil.isValidUsernameForSearch(filter)) {
cursorList.add(getUsernameSearchHeaderCursor());
cursorList.add(getUsernameSearchCursor());
}
return cursorList;
}
private Cursor getRecentsHeaderCursor() {
@@ -279,7 +300,7 @@ public class ContactsCursorLoader extends CursorLoader {
private Cursor getNewNumberCursor() {
MatrixCursor newNumberCursor = new MatrixCursor(CONTACT_PROJECTION, 1);
newNumberCursor.addRow(new Object[] { null,
getContext().getString(R.string.contact_selection_list__unknown_contact),
getUnknownContactTitle(),
filter,
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"\u21e2",
@@ -290,7 +311,7 @@ public class ContactsCursorLoader extends CursorLoader {
private Cursor getUsernameSearchCursor() {
MatrixCursor cursor = new MatrixCursor(CONTACT_PROJECTION, 1);
cursor.addRow(new Object[] { null,
getContext().getString(R.string.contact_selection_list__unknown_contact),
getUnknownContactTitle(),
filter,
ContactsContract.CommonDataKinds.Phone.TYPE_CUSTOM,
"\u21e2",
@@ -298,6 +319,11 @@ public class ContactsCursorLoader extends CursorLoader {
return cursor;
}
private String getUnknownContactTitle() {
return getContext().getString(newConversation(mode) ? R.string.contact_selection_list__unknown_contact
: R.string.contact_selection_list__unknown_contact_add_to_group);
}
private @NonNull Cursor filterNonPushContacts(@NonNull Cursor cursor) {
try {
final long startMillis = System.currentTimeMillis();
@@ -334,6 +360,10 @@ public class ContactsCursorLoader extends CursorLoader {
return flagSet(mode, DisplayMode.FLAG_SELF);
}
private static boolean newConversation(int mode) {
return groupsEnabled(mode);
}
private static boolean pushEnabled(int mode) {
return flagSet(mode, DisplayMode.FLAG_PUSH);
}

View File

@@ -43,13 +43,19 @@ public final class SelectedContact {
}
/**
* Returns true iff any non-null property matches one on the other contact.
* Returns true when non-null recipient ids match, and false if not.
* <p>
* If one or more recipient id is not set, then it returns true iff any other non-null property
* matches one on the other contact.
*/
public boolean matches(@Nullable SelectedContact other) {
if (other == null) return false;
return recipientId != null && recipientId.equals(other.recipientId) ||
number != null && number .equals(other.number) ||
username != null && username .equals(other.username);
if (recipientId != null && other.recipientId != null) {
return recipientId.equals(other.recipientId);
}
return number != null && number .equals(other.number) ||
username != null && username.equals(other.username);
}
}

View File

@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
@@ -178,9 +177,9 @@ class DirectoryHelperV1 {
if (insertResult.isPresent()) {
int hour = Calendar.getInstance().get(Calendar.HOUR_OF_DAY);
if (hour >= 9 && hour < 23) {
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), true);
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), true);
} else {
MessageNotifier.updateNotification(context, insertResult.get().getThreadId(), false);
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId(), false);
}
}
}

View File

@@ -643,7 +643,7 @@ public class Contact implements Parcelable {
private static Attachment attachmentFromUri(@Nullable Uri uri) {
if (uri == null) return null;
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, null, null, null, null);
return new UriAttachment(uri, MediaUtil.IMAGE_JPEG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, false, false, null, null, null, null, null);
}
@Override

View File

@@ -43,6 +43,7 @@ import android.provider.Telephony;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.Menu;
import android.view.MenuInflater;
@@ -125,6 +126,7 @@ import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -157,13 +159,13 @@ import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.groups.ui.GroupErrors;
import org.thoughtcrime.securesms.groups.ui.LeaveGroupDialog;
import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
import org.thoughtcrime.securesms.groups.ui.pendingmemberinvites.PendingMemberInvitesActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel;
@@ -194,7 +196,6 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.StickerSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
@@ -281,13 +282,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private static final String TAG = ConversationActivity.class.getSimpleName();
public static final String RECIPIENT_EXTRA = "recipient_id";
public static final String THREAD_ID_EXTRA = "thread_id";
public static final String TEXT_EXTRA = "draft_text";
public static final String MEDIA_EXTRA = "media_list";
public static final String STICKER_EXTRA = "sticker_extra";
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
public static final String STARTING_POSITION_EXTRA = "starting_position";
public static final String RECIPIENT_EXTRA = "recipient_id";
public static final String THREAD_ID_EXTRA = "thread_id";
public static final String TEXT_EXTRA = "draft_text";
public static final String MEDIA_EXTRA = "media_list";
public static final String STICKER_EXTRA = "sticker_extra";
public static final String DISTRIBUTION_TYPE_EXTRA = "distribution_type";
public static final String STARTING_POSITION_EXTRA = "starting_position";
public static final String HIGHLIGHT_STARTING_POSITION_EXTRA = "highlight_starting_position";
private static final int PICK_GALLERY = 1;
private static final int PICK_DOCUMENT = 2;
@@ -338,6 +340,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private ConversationStickerViewModel stickerViewModel;
private ConversationViewModel viewModel;
private InviteReminderModel inviteReminderModel;
private ConversationGroupViewModel groupViewModel;
private LiveRecipient recipient;
private long threadId;
@@ -355,13 +358,15 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
@NonNull RecipientId recipientId,
long threadId,
int distributionType,
int startingPosition)
int startingPosition,
boolean highlightStartingPosition)
{
Intent intent = new Intent(context, ConversationActivity.class);
intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId);
intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, distributionType);
intent.putExtra(ConversationActivity.STARTING_POSITION_EXTRA, startingPosition);
intent.putExtra(ConversationActivity.HIGHLIGHT_STARTING_POSITION_EXTRA, highlightStartingPosition);
return intent;
}
@@ -403,6 +408,8 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
initializeSearchObserver();
initializeStickerObserver();
initializeViewModel();
initializeGroupViewModel();
initializeEnabledCheck();
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
@@ -482,7 +489,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
dynamicLanguage.onResume(this);
EventBus.getDefault().register(this);
initializeEnabledCheck();
initializeMmsEnabledCheck();
initializeIdentityRecords();
composeText.setTransport(sendButton.getSelectedTransport());
@@ -499,14 +505,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(recipientSnapshot.getGroupId().get().requireV2()));
}
MessageNotifier.setVisibleThread(threadId);
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
markThreadAsRead();
}
@Override
protected void onPause() {
super.onPause();
MessageNotifier.setVisibleThread(-1L);
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
if (isFinishing()) overridePendingTransition(R.anim.fade_scale_in, R.anim.slide_to_end);
inputPanel.onPause();
@@ -701,8 +707,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
MenuInflater inflater = this.getMenuInflater();
menu.clear();
GroupActiveState groupActiveState = groupViewModel.getGroupActiveState().getValue();
boolean isActiveGroup = groupActiveState != null && groupActiveState.isActiveGroup();
boolean isActiveV2Group = groupActiveState != null && groupActiveState.isActiveV2Group();
if (isInMessageRequest()) {
if (isActiveGroup()) {
if (isActiveGroup) {
inflater.inflate(R.menu.conversation_message_requests_group, menu);
}
@@ -738,9 +748,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
} else {
menu.findItem(R.id.menu_distribution_conversation).setChecked(true);
}
} else if (isActiveV2Group()) {
} else if (isActiveV2Group || isActiveGroup && FeatureFlags.newGroupUI()) {
inflater.inflate(R.menu.conversation_push_group_v2_options, menu);
} else if (isActiveGroup()) {
} else if (isActiveGroup) {
inflater.inflate(R.menu.conversation_push_group_options, menu);
}
}
@@ -753,8 +763,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
inflater.inflate(R.menu.conversation_insecure, menu);
}
if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu);
else inflater.inflate(R.menu.conversation_unmuted, menu);
if (!FeatureFlags.newGroupUI()) {
if (recipient != null && recipient.get().isMuted()) inflater.inflate(R.menu.conversation_muted, menu);
else inflater.inflate(R.menu.conversation_unmuted, menu);
}
if (isSingleConversation() && getRecipient().getContactUri() == null) {
inflater.inflate(R.menu.conversation_add_to_contacts, menu);
@@ -784,6 +796,13 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
hideMenuItem(menu, R.id.menu_mute_notifications);
}
if (isActiveV2Group) {
hideMenuItem(menu, R.id.menu_mute_notifications);
hideMenuItem(menu, R.id.menu_conversation_settings);
} else if (isActiveGroup) {
hideMenuItem(menu, R.id.menu_conversation_settings);
}
searchViewItem = menu.findItem(R.id.menu_search);
SearchView searchView = (SearchView) searchViewItem.getActionView();
@@ -855,8 +874,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
case R.id.menu_distribution_broadcast: handleDistributionBroadcastEnabled(item); return true;
case R.id.menu_distribution_conversation: handleDistributionConversationEnabled(item); return true;
case R.id.menu_edit_group: handleEditPushGroupV1(); return true;
case R.id.menu_manage_group: handleManagePushGroup(); return true;
case R.id.menu_pending_members: handlePendingMembers(); return true;
case R.id.menu_group_settings: handleManagePushGroup(); return true;
case R.id.menu_leave: handleLeavePushGroup(); return true;
case R.id.menu_invite: handleInviteLink(); return true;
case R.id.menu_mute_notifications: handleMuteNotifications(); return true;
@@ -870,6 +888,28 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return false;
}
@Override
public boolean onMenuOpened(int featureId, Menu menu) {
if (menu == null) {
return super.onMenuOpened(featureId, null);
}
if (!SignalStore.uiHints().hasSeenGroupSettingsMenuToast()) {
MenuItem settingsMenuItem = menu.findItem(R.id.menu_group_settings);
if (settingsMenuItem != null && settingsMenuItem.isVisible()) {
Toast toast = Toast.makeText(this, R.string.ConversationActivity__more_options_now_in_group_settings, Toast.LENGTH_SHORT);
toast.setGravity(Gravity.CENTER, 0, 0);
toast.show();
SignalStore.uiHints().markHasSeenGroupSettingsMenuToast();
}
}
return super.onMenuOpened(featureId, menu);
}
@Override
public void onBackPressed() {
Log.d(TAG, "onBackPressed()");
@@ -991,6 +1031,13 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void handleConversationSettings() {
if (FeatureFlags.newGroupUI() && isPushGroupConversation()) {
startActivitySceneTransition(ManageGroupActivity.newIntent(this, getRecipient().requireGroupId().requirePush()),
titleView.findViewById(R.id.contact_photo_image),
"avatar");
return;
}
if (isInMessageRequest()) return;
Intent intent = RecipientPreferenceActivity.getLaunchIntent(this, recipient.getId());
@@ -1147,7 +1194,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
LeaveGroupDialog.handleLeavePushGroup(ConversationActivity.this,
getLifecycle(),
getRecipient().requireGroupId().requirePush(),
this::initializeEnabledCheck);
null);
}
private void handleEditPushGroupV1() {
@@ -1155,11 +1202,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void handleManagePushGroup() {
startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId()), GROUP_EDIT);
}
private void handlePendingMembers() {
startActivity(PendingMemberInvitesActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId().requireV2()));
startActivityForResult(ManageGroupActivity.newIntent(ConversationActivity.this, recipient.get().requireGroupId().requirePush()), GROUP_EDIT);
}
private void handleDistributionBroadcastEnabled(MenuItem item) {
@@ -1381,10 +1424,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
}
private void initializeEnabledCheck() {
boolean enabled = !(isPushGroupConversation() && !isActiveGroup());
inputPanel.setEnabled(enabled);
sendButton.setEnabled(enabled);
attachButton.setEnabled(enabled);
groupViewModel.getGroupActiveState().observe(this, state -> {
boolean enabled = state == null || !(isPushGroupConversation() && !state.isActiveGroup());
inputPanel.setEnabled(enabled);
sendButton.setEnabled(enabled);
attachButton.setEnabled(enabled);
});
}
private ListenableFuture<Boolean> initializeDraftFromDatabase() {
@@ -1815,6 +1860,12 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
this.viewModel = ViewModelProviders.of(this, new ConversationViewModel.Factory()).get(ConversationViewModel.class);
}
private void initializeGroupViewModel() {
groupViewModel = ViewModelProviders.of(this, new ConversationGroupViewModel.Factory()).get(ConversationGroupViewModel.class);
recipient.observe(this, groupViewModel::onRecipientChange);
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
}
private void showStickerIntroductionTooltip() {
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
inputPanel.setMediaKeyboardToggleMode(true);
@@ -1912,6 +1963,10 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (searchViewItem == null || !searchViewItem.isActionViewExpanded()) {
invalidateOptionsMenu();
}
if (groupViewModel != null) {
groupViewModel.onRecipientChange(recipient);
}
}
@Subscribe(threadMode = ThreadMode.MAIN)
@@ -2169,13 +2224,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
return record.isPresent() && record.get().isActive();
}
private boolean isActiveV2Group() {
if (!isGroupConversation()) return false;
Optional<GroupRecord> record = DatabaseFactory.getGroupDatabase(this).getGroup(getRecipient().getId());
return record.isPresent() && record.get().isActive() && record.get().isV2Group();
}
@SuppressWarnings("SimplifiableIfStatement")
private boolean isSelfConversation() {
if (!TextSecurePreferences.isPushRegistered(this)) return false;
@@ -2230,7 +2278,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
Context context = ConversationActivity.this;
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(params[0], false);
MessageNotifier.updateNotification(context);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
return null;
@@ -2260,7 +2308,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (refreshFragment) {
fragment.reload(recipient.get(), threadId);
MessageNotifier.setVisibleThread(threadId);
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
}
fragment.scrollToBottom();

View File

@@ -72,12 +72,14 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
private static final String TAG = Log.tag(ConversationAdapter.class);
private static final int MESSAGE_TYPE_OUTGOING = 0;
private static final int MESSAGE_TYPE_INCOMING = 1;
private static final int MESSAGE_TYPE_UPDATE = 2;
private static final int MESSAGE_TYPE_HEADER = 3;
private static final int MESSAGE_TYPE_FOOTER = 4;
private static final int MESSAGE_TYPE_PLACEHOLDER = 5;
private static final int MESSAGE_TYPE_OUTGOING_MULTIMEDIA = 0;
private static final int MESSAGE_TYPE_OUTGOING_TEXT = 1;
private static final int MESSAGE_TYPE_INCOMING_MULTIMEDIA = 2;
private static final int MESSAGE_TYPE_INCOMING_TEXT = 3;
private static final int MESSAGE_TYPE_UPDATE = 4;
private static final int MESSAGE_TYPE_HEADER = 5;
private static final int MESSAGE_TYPE_FOOTER = 6;
private static final int MESSAGE_TYPE_PLACEHOLDER = 7;
private static final long HEADER_ID = Long.MIN_VALUE;
private static final long FOOTER_ID = Long.MIN_VALUE + 1;
@@ -136,9 +138,9 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
} else if (messageRecord.isUpdate()) {
return MESSAGE_TYPE_UPDATE;
} else if (messageRecord.isOutgoing()) {
return MESSAGE_TYPE_OUTGOING;
return messageRecord.isMms() ? MESSAGE_TYPE_OUTGOING_MULTIMEDIA : MESSAGE_TYPE_OUTGOING_TEXT;
} else {
return MESSAGE_TYPE_INCOMING;
return messageRecord.isMms() ? MESSAGE_TYPE_INCOMING_MULTIMEDIA : MESSAGE_TYPE_INCOMING_TEXT;
}
}
@@ -167,8 +169,10 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
@Override
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
case MESSAGE_TYPE_INCOMING:
case MESSAGE_TYPE_OUTGOING:
case MESSAGE_TYPE_INCOMING_TEXT:
case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
case MESSAGE_TYPE_OUTGOING_TEXT:
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
case MESSAGE_TYPE_UPDATE:
long start = System.currentTimeMillis();
@@ -189,7 +193,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
itemView.setEventListener(clickListener);
Log.d(TAG, "Inflate time: " + (System.currentTimeMillis() - start));
Log.d(TAG, String.format(Locale.US, "Inflate time: %d ms for View type: %d", System.currentTimeMillis() - start, viewType));
return new ConversationViewHolder(itemView);
case MESSAGE_TYPE_PLACEHOLDER:
View v = new FrameLayout(parent.getContext());
@@ -206,8 +210,10 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
case MESSAGE_TYPE_INCOMING:
case MESSAGE_TYPE_OUTGOING:
case MESSAGE_TYPE_INCOMING_TEXT:
case MESSAGE_TYPE_INCOMING_MULTIMEDIA:
case MESSAGE_TYPE_OUTGOING_TEXT:
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
case MESSAGE_TYPE_UPDATE:
ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
@@ -358,8 +364,10 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
*/
void pulseHighlightItem(int position) {
if (position >= 0 && position < getItemCount()) {
recordToPulseHighlight = getItem(position);
notifyItemChanged(position);
int correctedPosition = isHeaderPosition(position) ? position + 1 : position;
recordToPulseHighlight = getItem(correctedPosition);
notifyItemChanged(correctedPosition);
}
}
@@ -422,8 +430,10 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
*/
@MainThread
static void initializePool(@NonNull RecyclerView.RecycledViewPool pool) {
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_TEXT, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_INCOMING_MULTIMEDIA, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_TEXT, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_OUTGOING_MULTIMEDIA, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_PLACEHOLDER, 15);
pool.setMaxRecycledViews(MESSAGE_TYPE_HEADER, 1);
pool.setMaxRecycledViews(MESSAGE_TYPE_FOOTER, 1);
@@ -462,12 +472,14 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
return hasFooter() && position == (getItemCount() - 1);
}
private @LayoutRes int getLayoutForViewType(int viewType) {
private static @LayoutRes int getLayoutForViewType(int viewType) {
switch (viewType) {
case MESSAGE_TYPE_OUTGOING: return R.layout.conversation_item_sent;
case MESSAGE_TYPE_INCOMING: return R.layout.conversation_item_received;
case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
default: throw new IllegalArgumentException("Unknown type!");
case MESSAGE_TYPE_OUTGOING_TEXT: return R.layout.conversation_item_sent_text_only;
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA: return R.layout.conversation_item_sent_multimedia;
case MESSAGE_TYPE_INCOMING_TEXT: return R.layout.conversation_item_received_text_only;
case MESSAGE_TYPE_INCOMING_MULTIMEDIA: return R.layout.conversation_item_received_multimedia;
case MESSAGE_TYPE_UPDATE: return R.layout.conversation_item_update;
default: throw new IllegalArgumentException("Unknown type!");
}
}

View File

@@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executor;
/**
@@ -50,7 +49,10 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
}
};
invalidator.observe(this::invalidate);
invalidator.observe(() -> {
invalidate();
context.getContentResolver().unregisterContentObserver(contentObserver);
});
context.getContentResolver().registerContentObserver(DatabaseContentProviders.Conversation.getUriForThread(threadId), true, contentObserver);
}
@@ -59,36 +61,30 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<MessageRecord> callback) {
long start = System.currentTimeMillis();
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
List<MessageRecord> records = new ArrayList<>(params.requestedLoadSize);
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
List<MessageRecord> records = new ArrayList<>(params.requestedLoadSize);
int totalCount = db.getConversationCount(threadId);
int effectiveCount = params.requestedStartPosition;
if (totalCount == 0 || params.requestedStartPosition > totalCount) {
}
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
MessageRecord record;
while ((record = reader.getNext()) != null && effectiveCount < totalCount && !isInvalid()) {
records.add(record);
effectiveCount++;
}
}
if (!isInvalid()) {
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.requestedStartPosition, params.requestedLoadSize))) {
MessageRecord record;
while ((record = reader.getNext()) != null && !isInvalid()) {
records.add(record);
}
}
} else {
Log.i(TAG, "[Initial Load] Invalidated before we could even query!");
SizeFixResult result = ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
callback.onResult(result.messages, params.requestedStartPosition, result.total);
Util.runOnMain(dataUpdateCallback::onDataUpdated);
}
int effectiveCount = records.size() + params.requestedStartPosition;
int totalCount = db.getConversationCount(threadId);
if (effectiveCount > totalCount) {
Log.w(TAG, String.format(Locale.ENGLISH, "Miscalculation! Records: %d, Start Position: %d, Total: %d. Adjusting total.",
records.size(),
params.requestedStartPosition,
totalCount));
totalCount = effectiveCount;
}
records = ensureMultipleOfPageSize(records, params.pageSize, totalCount);
callback.onResult(records, params.requestedStartPosition, totalCount);
Util.runOnMain(dataUpdateCallback::onDataUpdated);
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
}
@@ -99,15 +95,11 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
List<MessageRecord> records = new ArrayList<>(params.loadSize);
if (!isInvalid()) {
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
MessageRecord record;
while ((record = reader.getNext()) != null && !isInvalid()) {
records.add(record);
}
try (MmsSmsDatabase.Reader reader = db.readerFor(db.getConversation(threadId, params.startPosition, params.loadSize))) {
MessageRecord record;
while ((record = reader.getNext()) != null && !isInvalid()) {
records.add(record);
}
} else {
Log.i(TAG, "[Update] Invalidated before we could even query!");
}
callback.onResult(records);
@@ -116,12 +108,33 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms" + (isInvalid() ? " -- invalidated" : ""));
}
private static @NonNull List<MessageRecord> ensureMultipleOfPageSize(@NonNull List<MessageRecord> records, int pageSize, int total) {
if (records.size() != total && records.size() % pageSize != 0) {
int overflow = records.size() % pageSize;
return records.subList(0, records.size() - overflow);
} else {
return records;
private static @NonNull SizeFixResult ensureMultipleOfPageSize(@NonNull List<MessageRecord> records,
int startPosition,
int pageSize,
int total)
{
if (records.size() + startPosition == total || (records.size() != 0 && records.size() % pageSize == 0)) {
return new SizeFixResult(records, total);
}
if (records.size() < pageSize) {
Log.w(TAG, "Hit a miscalculation where we don't have the full dataset, but it's smaller than a page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total);
return new SizeFixResult(records, records.size() + startPosition);
}
Log.w(TAG, "Hit a miscalculation where our data size isn't a multiple of the page size. records: " + records.size() + ", startPosition: " + startPosition + ", pageSize: " + pageSize + ", total: " + total);
int overflow = records.size() % pageSize;
return new SizeFixResult(records.subList(0, records.size() - overflow), total);
}
private static class SizeFixResult {
final List<MessageRecord> messages;
final int total;
private SizeFixResult(@NonNull List<MessageRecord> messages, int total) {
this.messages = messages;
this.total = total;
}
}

View File

@@ -167,8 +167,8 @@ public class ConversationFragment extends Fragment {
FrameLayout parent = new FrameLayout(context);
parent.setLayoutParams(new FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT));
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received, parent, 10);
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent, parent, 10);
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_received_multimedia, parent, 10);
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_sent_multimedia, parent, 10);
CachedInflater.from(context).cacheUntilLimit(R.layout.conversation_item_update, parent, 5);
CachedInflater.from(context).cacheUntilLimit(R.layout.cursor_adapter_header_footer_view, parent, 2);
}
@@ -217,6 +217,7 @@ public class ConversationFragment extends Fragment {
this.conversationViewModel = ViewModelProviders.of(requireActivity(), new ConversationViewModel.Factory()).get(ConversationViewModel.class);
conversationViewModel.getMessages().observe(this, list -> {
if (getListAdapter() != null) {
Log.i(TAG, "submitList");
getListAdapter().submitList(list);
}
});
@@ -283,6 +284,7 @@ public class ConversationFragment extends Fragment {
super.onResume();
if (list.getAdapter() != null) {
Log.i(TAG, "onResume notifyDataSetChanged");
list.getAdapter().notifyDataSetChanged();
}
}
@@ -905,10 +907,17 @@ public class ConversationFragment extends Fragment {
private void scrollToStartingPosition(int startingPosition) {
list.post(() -> {
list.getLayoutManager().scrollToPosition(startingPosition);
getListAdapter().pulseHighlightItem(startingPosition);
if (shouldHighlightStartingPosition()) {
getListAdapter().pulseHighlightItem(startingPosition);
}
});
}
private boolean shouldHighlightStartingPosition() {
return requireActivity().getIntent().getBooleanExtra(ConversationActivity.HIGHLIGHT_STARTING_POSITION_EXTRA, false);
}
private void scrollToLastSeenPosition(int lastSeenPosition) {
if (lastSeenPosition > 0) {
list.post(() -> getListLayoutManager().scrollToPositionWithOffset(lastSeenPosition, list.getHeight()));

View File

@@ -0,0 +1,81 @@
package org.thoughtcrime.securesms.conversation;
import android.app.Application;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupDatabase.GroupRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
class ConversationGroupViewModel extends ViewModel {
private final MutableLiveData<Recipient> liveRecipient;
private final LiveData<GroupActiveState> groupActiveState;
private ConversationGroupViewModel() {
liveRecipient = new MutableLiveData<>();
LiveData<GroupRecord> groupRecord = LiveDataUtil.mapAsync(liveRecipient, this::getGroupRecordForRecipient);
groupActiveState = Transformations.distinctUntilChanged(Transformations.map(groupRecord, this::mapToGroupActiveState));
}
void onRecipientChange(Recipient recipient) {
liveRecipient.setValue(recipient);
}
LiveData<GroupActiveState> getGroupActiveState() {
return groupActiveState;
}
private GroupRecord getGroupRecordForRecipient(Recipient recipient) {
if (recipient != null && recipient.isGroup()) {
Application context = ApplicationDependencies.getApplication();
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
return groupDatabase.getGroup(recipient.getId()).orNull();
} else {
return null;
}
}
private GroupActiveState mapToGroupActiveState(@Nullable GroupRecord record) {
if (record == null) {
return null;
}
return new GroupActiveState(record.isActive(), record.isV2Group());
}
static final class GroupActiveState {
private final boolean isActive;
private final boolean isActiveV2;
public GroupActiveState(boolean isActive, boolean isV2) {
this.isActive = isActive;
this.isActiveV2 = isActive && isV2;
}
public boolean isActiveGroup() {
return isActive;
}
public boolean isActiveV2Group() {
return isActiveV2;
}
}
static class Factory extends ViewModelProvider.NewInstanceFactory {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ConversationGroupViewModel());
}
}
}

View File

@@ -104,7 +104,6 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment;
import org.thoughtcrime.securesms.revealable.ViewOnceMessageView;
import org.thoughtcrime.securesms.revealable.ViewOnceUtil;
import org.thoughtcrime.securesms.stickers.StickerUrl;
@@ -150,20 +149,20 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private LiveRecipient recipient;
private GlideRequests glideRequests;
protected ConversationItemBodyBubble bodyBubble;
protected View reply;
protected ViewGroup contactPhotoHolder;
private QuoteView quoteView;
private EmojiTextView bodyText;
private ConversationItemFooter footer;
private ConversationItemFooter stickerFooter;
private TextView groupSender;
private TextView groupSenderProfileName;
private View groupSenderHolder;
private AvatarImageView contactPhoto;
private AlertView alertView;
private ViewGroup container;
protected ReactionsConversationView reactionsView;
protected ConversationItemBodyBubble bodyBubble;
protected View reply;
@Nullable protected ViewGroup contactPhotoHolder;
@Nullable private QuoteView quoteView;
private EmojiTextView bodyText;
private ConversationItemFooter footer;
private ConversationItemFooter stickerFooter;
@Nullable private TextView groupSender;
@Nullable private TextView groupSenderProfileName;
@Nullable private View groupSenderHolder;
private AvatarImageView contactPhoto;
private AlertView alertView;
private ViewGroup container;
protected ReactionsConversationView reactionsView;
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -285,6 +284,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
@Override
protected void onDetachedFromWindow() {
ConversationSwipeAnimationHelper.update(this, 0f, 1f);
unbind();
super.onDetachedFromWindow();
}
@@ -311,6 +311,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
boolean needsMeasure = false;
if (hasQuote(messageRecord)) {
if (quoteView == null) {
throw new AssertionError();
}
int quoteWidth = quoteView.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(quoteView);
@@ -343,9 +346,10 @@ public class ConversationItem extends LinearLayout implements BindableConversati
@Override
public void onRecipientChanged(@NonNull Recipient modified) {
setBubbleState(messageRecord);
setContactPhoto(recipient.get());
setGroupMessageStatus(messageRecord, recipient.get());
setAudioViewTint(messageRecord, conversationRecipient.get());
if (recipient.getId().equals(modified.getId())) {
setContactPhoto(modified);
setGroupMessageStatus(messageRecord, modified);
}
}
private int getAvailableMessageBubbleWidth(@NonNull View forView) {
@@ -376,6 +380,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
if (recipient != null) {
recipient.removeForeverObserver(this);
}
if (conversationRecipient != null) {
conversationRecipient.removeForeverObserver(this);
}
}
public MessageRecord getMessageRecord() {
@@ -403,19 +410,21 @@ public class ConversationItem extends LinearLayout implements BindableConversati
bodyBubble.setOutliner(shouldDrawBodyBubbleOutline(messageRecord) ? outliner : null);
if (audioViewStub.resolved()) {
setAudioViewTint(messageRecord, this.conversationRecipient.get());
setAudioViewTint(messageRecord);
}
}
private void setAudioViewTint(MessageRecord messageRecord, Recipient recipient) {
if (messageRecord.isOutgoing()) {
if (DynamicTheme.isDarkTheme(context)) {
audioViewStub.get().setTint(Color.WHITE, defaultBubbleColor);
private void setAudioViewTint(MessageRecord messageRecord) {
if (hasAudio(messageRecord)) {
if (messageRecord.isOutgoing()) {
if (DynamicTheme.isDarkTheme(context)) {
audioViewStub.get().setTint(Color.WHITE);
} else {
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60));
}
} else {
audioViewStub.get().setTint(getContext().getResources().getColor(R.color.core_grey_60), defaultBubbleColor);
audioViewStub.get().setTint(Color.WHITE);
}
} else {
audioViewStub.get().setTint(Color.WHITE, recipient.getColor().toConversationColor(context));
}
}
@@ -586,7 +595,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setSharedContactCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(GONE);
} else if (hasLinkPreview(messageRecord)) {
linkPreviewStub.get().setVisibility(View.VISIBLE);
@@ -613,13 +622,13 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, true);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
} else {
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, true);
linkPreviewStub.get().setDownloadClickedListener(downloadClickListener);
setLinkPreviewCorners(messageRecord, previousRecord, nextRecord, isGroupThread, false);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
linkPreviewStub.get().setOnClickListener(linkPreviewClickListener);
@@ -642,7 +651,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
} else if (hasDocument(messageRecord)) {
@@ -661,7 +670,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
documentViewStub.get().setOnLongClickListener(passthroughClickListener);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
} else if (hasSticker(messageRecord) && isCaptionlessMms(messageRecord)) {
@@ -683,7 +692,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
stickerStub.get().setOnClickListener(passthroughClickListener);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
} else if (hasThumbnail(messageRecord)) {
@@ -713,7 +722,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setThumbnailCorners(messageRecord, previousRecord, nextRecord, isGroupThread);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
} else {
@@ -726,7 +735,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParams(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
ViewUtil.updateLayoutParamsIfNonNull(groupSenderHolder, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
footer.setVisibility(VISIBLE);
}
@@ -870,6 +879,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (current.isMms() && !current.isMmsNotification() && ((MediaMmsMessageRecord)current).getQuote() != null) {
if (quoteView == null) {
throw new AssertionError();
}
Quote quote = ((MediaMmsMessageRecord)current).getQuote();
//noinspection ConstantConditions
quoteView.setQuote(glideRequests, quote.getId(), Recipient.live(quote.getAuthor()).get(), quote.getText(), quote.isOriginalMissing(), quote.getAttachment());
@@ -906,7 +918,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
ViewUtil.setTopMargin(mediaThumbnailStub.get(), readDimen(R.dimen.message_bubble_top_padding));
}
} else {
quoteView.dismiss();
if (quoteView != null) {
quoteView.dismiss();
}
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.get(), 0);
@@ -987,38 +1001,42 @@ public class ConversationItem extends LinearLayout implements BindableConversati
@SuppressLint("SetTextI18n")
private void setGroupMessageStatus(MessageRecord messageRecord, Recipient recipient) {
if (groupThread && !messageRecord.isOutgoing()) {
if (groupThread && !messageRecord.isOutgoing() && groupSender != null && groupSenderProfileName != null) {
if (FeatureFlags.profileDisplay()) {
this.groupSender.setText(recipient.getDisplayName(getContext()));
this.groupSenderProfileName.setVisibility(View.GONE);
groupSender.setText(recipient.getDisplayName(getContext()));
groupSenderProfileName.setVisibility(View.GONE);
} else {
this.groupSender.setText(recipient.toShortString(context));
groupSender.setText(recipient.toShortString(context));
if (recipient.getName(context) == null && !recipient.getProfileName().isEmpty()) {
this.groupSenderProfileName.setText("~" + recipient.getProfileName().toString());
this.groupSenderProfileName.setVisibility(View.VISIBLE);
groupSenderProfileName.setText("~" + recipient.getProfileName().toString());
groupSenderProfileName.setVisibility(View.VISIBLE);
} else {
this.groupSenderProfileName.setText(null);
this.groupSenderProfileName.setVisibility(View.GONE);
groupSenderProfileName.setText(null);
groupSenderProfileName.setVisibility(View.GONE);
}
}
}
}
private void setGroupAuthorColor(@NonNull MessageRecord messageRecord) {
if (shouldDrawBodyBubbleOutline(messageRecord)) {
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
} else if (hasSticker(messageRecord)) {
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color));
} else {
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
if (groupSender != null && groupSenderProfileName != null) {
int stickerAuthorColor = ThemeUtil.getThemedColor(context, R.attr.conversation_sticker_author_color);
if (shouldDrawBodyBubbleOutline(messageRecord)) {
groupSender.setTextColor(stickerAuthorColor);
groupSenderProfileName.setTextColor(stickerAuthorColor);
} else if (hasSticker(messageRecord)) {
groupSender.setTextColor(stickerAuthorColor);
groupSenderProfileName.setTextColor(stickerAuthorColor);
} else {
groupSender.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
groupSenderProfileName.setTextColor(ThemeUtil.getThemedColor(context, R.attr.conversation_item_received_text_primary_color));
}
}
}
@SuppressWarnings("ConstantConditions")
private void setAuthor(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (isGroupThread && !current.isOutgoing()) {
contactPhotoHolder.setVisibility(VISIBLE);
@@ -1037,7 +1055,9 @@ public class ConversationItem extends LinearLayout implements BindableConversati
contactPhoto.setVisibility(GONE);
}
} else {
groupSenderHolder.setVisibility(GONE);
if (groupSenderHolder != null) {
groupSenderHolder.setVisibility(GONE);
}
if (contactPhotoHolder != null) {
contactPhotoHolder.setVisibility(GONE);

View File

@@ -120,8 +120,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
.map(e -> findViewById(e.viewId))
.toArray(EmojiImageView[]::new);
customEmojiIndex = FeatureFlags.reactWithAnyEmoji() ? ReactionEmoji.values().length - 1
: ReactionEmoji.values().length;
customEmojiIndex = ReactionEmoji.values().length - 1;
distanceFromTouchDownPointToTopOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_top);
distanceFromTouchDownPointToBottomOfScrubberDeadZone = getResources().getDimensionPixelSize(R.dimen.conversation_reaction_scrub_deadzone_distance_from_touch_bottom);

View File

@@ -2,9 +2,6 @@ package org.thoughtcrime.securesms.conversation;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -12,6 +9,9 @@ import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
@@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -152,8 +151,6 @@ public class ConversationTitleView extends RelativeLayout {
}
private void setGroupRecipientTitle(Recipient recipient) {
String localNumber = TextSecurePreferences.getLocalNumber(getContext());
if (FeatureFlags.profileDisplay()) {
this.title.setText(recipient.getDisplayName(getContext()));
} else {
@@ -161,8 +158,9 @@ public class ConversationTitleView extends RelativeLayout {
}
this.subtitle.setText(Stream.of(recipient.getParticipants())
.filterNot(Recipient::isLocalNumber)
.map(r -> r.toShortString(getContext()))
.sorted((a, b) -> Boolean.compare(a.isLocalNumber(), b.isLocalNumber()))
.map(r -> r.isLocalNumber() ? getResources().getString(R.string.ConversationTitleView_you)
: r.getDisplayName(getContext()))
.collect(Collectors.joining(", ")));
updateSubtitleVisibility();

View File

@@ -87,6 +87,12 @@ public class ConversationUpdateItem extends LinearLayout
bind(messageRecord, locale);
}
@Override
protected void onDetachedFromWindow() {
unbind();
super.onDetachedFromWindow();
}
@Override
public void setEventListener(@Nullable EventListener listener) {
// No events to report yet

View File

@@ -25,6 +25,9 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
@@ -37,8 +40,10 @@ import org.thoughtcrime.securesms.util.Conversions;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
@@ -59,9 +64,9 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
private final @Nullable ItemClickListener clickListener;
private final @NonNull MessageDigest digest;
private final Set<Long> batchSet = Collections.synchronizedSet(new HashSet<Long>());
private boolean batchMode = false;
private final Set<Long> typingSet = new HashSet<>();
private final Map<Long, ThreadRecord> batchSet = Collections.synchronizedMap(new HashMap<>());
private boolean batchMode = false;
private final Set<Long> typingSet = new HashSet<>();
protected static class ViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationListItem> ViewHolder(final @NonNull V itemView)
@@ -143,7 +148,7 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
@Override
public void onBindItemViewHolder(ViewHolder viewHolder, @NonNull Cursor cursor) {
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet, batchMode);
viewHolder.getItem().bind(getThreadRecord(cursor), glideRequests, locale, typingSet, batchSet.keySet(), batchMode);
}
@Override
@@ -169,16 +174,20 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
return threadDatabase.readerFor(cursor).getCurrent();
}
void toggleThreadInBatchSet(long threadId) {
if (batchSet.contains(threadId)) {
batchSet.remove(threadId);
} else if (threadId != -1) {
batchSet.add(threadId);
void toggleThreadInBatchSet(@NonNull ThreadRecord thread) {
if (batchSet.containsKey(thread.getThreadId())) {
batchSet.remove(thread.getThreadId());
} else if (thread.getThreadId() != -1) {
batchSet.put(thread.getThreadId(), thread);
}
}
Set<Long> getBatchSelections() {
return batchSet;
@NonNull Set<Long> getBatchSelectionIds() {
return batchSet.keySet();
}
@NonNull Set<ThreadRecord> getBatchSelection() {
return new HashSet<>(batchSet.values());
}
void initializeBatchMode(boolean toggle) {
@@ -193,8 +202,10 @@ class ConversationListAdapter extends CursorRecyclerViewAdapter<ConversationList
void selectAllThreads() {
for (int i = 0; i < getItemCount(); i++) {
long threadId = getThreadRecord(getCursorAtPositionOrThrow(i)).getThreadId();
if (threadId != -1) batchSet.add(threadId);
ThreadRecord record = getThreadRecord(getCursorAtPositionOrThrow(i));
if (record.getThreadId() != -1) {
batchSet.put(record.getThreadId(), record);
}
}
this.notifyDataSetChanged();
}

View File

@@ -65,6 +65,7 @@ import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import com.google.android.material.snackbar.Snackbar;
import org.greenrobot.eventbus.EventBus;
@@ -90,7 +91,6 @@ import org.thoughtcrime.securesms.components.reminder.ServiceOutageReminder;
import org.thoughtcrime.securesms.components.reminder.ShareReminder;
import org.thoughtcrime.securesms.components.reminder.SystemSmsImportReminder;
import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder;
import org.thoughtcrime.securesms.conversation.ConversationFragment;
import org.thoughtcrime.securesms.conversationlist.ConversationListAdapter.ItemClickListener;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
@@ -103,7 +103,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
import org.thoughtcrime.securesms.lock.RegistrationLockV1Dialog;
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mediasend.MediaSendActivity;
@@ -113,13 +112,12 @@ import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder;
import org.thoughtcrime.securesms.megaphone.Megaphones;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.notifications.MarkReadReceiver;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.StickyHeaderDecoration;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -128,6 +126,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask;
import org.whispersystems.libsignal.util.Pair;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Collections;
@@ -240,8 +239,6 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
RatingManager.showRatingDialogIfNecessary(requireContext());
RegistrationLockV1Dialog.showReminderIfNecessary(this);
TooltipCompat.setTooltipText(searchAction, getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
}
@@ -356,19 +353,24 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
getNavigator().goToConversation(threadRecord.getRecipient().getId(),
threadRecord.getThreadId(),
threadRecord.getDistributionType(),
-1);
threadRecord.getUnreadCount(),
false);
}
@Override
public void onContactClicked(@NonNull Recipient contact) {
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact);
}, threadId -> {
long threadId = DatabaseFactory.getThreadDatabase(getContext()).getThreadIdIfExistsFor(contact);
int unreadCount = DatabaseFactory.getMmsSmsDatabase(getContext()).getUnreadCount(threadId);
return new Pair<>(threadId, unreadCount);
}, pair -> {
hideKeyboard();
getNavigator().goToConversation(contact.getId(),
threadId,
pair.first(),
ThreadDatabase.DistributionTypes.DEFAULT,
-1);
pair.second(),
false);
});
}
@@ -382,7 +384,8 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
getNavigator().goToConversation(message.conversationRecipient.getId(),
message.threadId,
ThreadDatabase.DistributionTypes.DEFAULT,
startingPosition);
startingPosition,
true);
});
}
@@ -591,11 +594,46 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
SignalExecutors.BOUNDED.execute(() -> {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setAllThreadsRead();
MessageNotifier.updateNotification(context);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
});
}
private void handleMarkSelectedAsRead() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(context).setRead(selectedConversations, false);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
MarkReadReceiver.process(context, messageIds);
return null;
}, none -> {
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
});
}
private void handleMarkSelectedAsUnread() {
Context context = requireContext();
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
DatabaseFactory.getThreadDatabase(context).setForcedUnread(selectedConversations);
StorageSyncHelper.scheduleSyncForDataChange();
return null;
}, none -> {
if (actionMode != null) {
actionMode.finish();
actionMode = null;
}
});
}
private void handleInvite() {
getNavigator().goToInvite();
}
@@ -606,7 +644,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@SuppressLint("StaticFieldLeak")
private void handleArchiveAllSelected() {
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelections());
Set<Long> selectedConversations = new HashSet<>(defaultAdapter.getBatchSelectionIds());
int count = selectedConversations.size();
String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count);
@@ -645,7 +683,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@SuppressLint("StaticFieldLeak")
private void handleDeleteAllSelected() {
int conversationsCount = defaultAdapter.getBatchSelections().size();
int conversationsCount = defaultAdapter.getBatchSelectionIds().size();
AlertDialog.Builder alert = new AlertDialog.Builder(getActivity());
alert.setIconAttribute(R.attr.dialog_alert_icon);
alert.setTitle(getActivity().getResources().getQuantityString(R.plurals.ConversationListFragment_delete_selected_conversations,
@@ -655,7 +693,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
alert.setCancelable(true);
alert.setPositiveButton(R.string.delete, (dialog, which) -> {
final Set<Long> selectedConversations = defaultAdapter.getBatchSelections();
final Set<Long> selectedConversations = defaultAdapter.getBatchSelectionIds();
if (!selectedConversations.isEmpty()) {
new AsyncTask<Void, Void, Void>() {
@@ -672,7 +710,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@Override
protected Void doInBackground(Void... params) {
DatabaseFactory.getThreadDatabase(getActivity()).deleteConversations(selectedConversations);
MessageNotifier.updateNotification(getActivity());
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
return null;
}
@@ -694,11 +732,11 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
private void handleSelectAllThreads() {
defaultAdapter.selectAllThreads();
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelections().size()));
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
}
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType, int unreadCount) {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, unreadCount, false);
}
@Override
@@ -732,15 +770,16 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@Override
public void onItemClick(ConversationListItem item) {
if (actionMode == null) {
handleCreateConversation(item.getThreadId(), item.getRecipient(), item.getDistributionType());
handleCreateConversation(item.getThreadId(), item.getRecipient(), item.getDistributionType(), item.getUnreadCount());
} else {
ConversationListAdapter adapter = (ConversationListAdapter)list.getAdapter();
adapter.toggleThreadInBatchSet(item.getThreadId());
adapter.toggleThreadInBatchSet(item.getThread());
if (adapter.getBatchSelections().size() == 0) {
if (adapter.getBatchSelectionIds().size() == 0) {
actionMode.finish();
} else {
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelections().size()));
actionMode.setTitle(String.valueOf(defaultAdapter.getBatchSelectionIds().size()));
setCorrectMenuVisibility(actionMode.getMenu());
}
adapter.notifyDataSetChanged();
@@ -752,7 +791,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this);
defaultAdapter.initializeBatchMode(true);
defaultAdapter.toggleThreadInBatchSet(item.getThreadId());
defaultAdapter.toggleThreadInBatchSet(item.getThread());
defaultAdapter.notifyDataSetChanged();
}
@@ -784,15 +823,18 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
setCorrectMenuVisibility(menu);
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_select_all: handleSelectAllThreads(); return true;
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
case R.id.menu_select_all: handleSelectAllThreads(); return true;
case R.id.menu_delete_selected: handleDeleteAllSelected(); return true;
case R.id.menu_archive_selected: handleArchiveAllSelected(); return true;
case R.id.menu_mark_as_read: handleMarkSelectedAsRead(); return true;
case R.id.menu_mark_as_unread: handleMarkSelectedAsUnread(); return true;
}
return false;
@@ -833,6 +875,18 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
closeSearchIfOpen();
}
private void setCorrectMenuVisibility(@NonNull Menu menu) {
boolean hasUnread = Stream.of(defaultAdapter.getBatchSelection()).anyMatch(thread -> !thread.isRead());
if (hasUnread) {
menu.findItem(R.id.menu_mark_as_unread).setVisible(false);
menu.findItem(R.id.menu_mark_as_read).setVisible(true);
} else {
menu.findItem(R.id.menu_mark_as_unread).setVisible(true);
menu.findItem(R.id.menu_mark_as_read).setVisible(false);
}
}
protected @IdRes int getToolbarRes() {
return R.id.toolbar;
}
@@ -873,7 +927,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
if (unreadCount > 0) {
List<MarkedMessageInfo> messageIds = DatabaseFactory.getThreadDatabase(getActivity()).setRead(threadId, false);
MessageNotifier.updateNotification(getActivity());
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
MarkReadReceiver.process(getActivity(), messageIds);
}
}
@@ -884,7 +938,7 @@ public class ConversationListFragment extends MainFragment implements LoaderMana
if (unreadCount > 0) {
DatabaseFactory.getThreadDatabase(getActivity()).incrementUnread(threadId, unreadCount);
MessageNotifier.updateNotification(getActivity());
ApplicationDependencies.getMessageNotifier().updateNotification(getActivity());
}
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId);

View File

@@ -22,6 +22,7 @@ import android.graphics.Typeface;
import android.graphics.drawable.RippleDrawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.StyleSpan;
import android.util.AttributeSet;
@@ -41,6 +42,9 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
@@ -48,8 +52,11 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Collections;
@@ -84,6 +91,7 @@ public class ConversationListItem extends RelativeLayout
private TextView unreadIndicator;
private long lastSeen;
private ThreadRecord thread;
private boolean batchMode;
private int unreadCount;
private AvatarImageView contactPhotoImage;
@@ -93,7 +101,7 @@ public class ConversationListItem extends RelativeLayout
private final RecipientForeverObserver groupAddedByObserver = adder -> {
if (isAttachedToWindow() && subjectView != null && thread != null) {
subjectView.setText(thread.getDisplayBody(getContext()));
subjectView.setText(getThreadDisplayBody(getContext(), thread));
}
};
@@ -162,7 +170,7 @@ public class ConversationListItem extends RelativeLayout
this.fromView.setText(SearchUtil.getHighlightedSpan(locale, () -> new StyleSpan(Typeface.BOLD), name, highlightSubstring));
} else {
this.fromView.setText(recipient.get(), unreadCount == 0);
this.fromView.setText(recipient.get(), thread.isRead());
}
if (typingThreads.contains(threadId)) {
@@ -175,24 +183,24 @@ public class ConversationListItem extends RelativeLayout
this.typingView.stopAnimation();
this.subjectView.setVisibility(VISIBLE);
this.subjectView.setText(getTrimmedSnippet(thread.getDisplayBody(getContext())));
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
if (thread.getGroupAddedBy() != null) {
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
groupAddedBy.observeForever(groupAddedByObserver);
}
this.subjectView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
this.subjectView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
}
if (thread.getDate() > 0) {
CharSequence date = DateUtils.getBriefRelativeTimeSpanString(getContext(), locale, thread.getDate());
dateView.setText(date);
dateView.setTypeface(unreadCount == 0 ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
dateView.setTextColor(unreadCount == 0 ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_date_color)
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
dateView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
dateView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_date_color)
: ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_unread_color));
}
if (thread.isArchived()) {
@@ -203,10 +211,10 @@ public class ConversationListItem extends RelativeLayout
setStatusIcons(thread);
setThumbnailSnippet(thread);
setBatchState(batchMode);
setBatchMode(batchMode);
setRippleColor(recipient.get());
setUnreadIndicator(thread);
this.contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
this.contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
}
public void bind(@NonNull Recipient contact,
@@ -233,9 +241,9 @@ public class ConversationListItem extends RelativeLayout
alertView.setNone();
thumbnailView.setVisibility(GONE);
setBatchState(false);
setBatchMode(false);
setRippleColor(contact);
contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
}
public void bind(@NonNull MessageResult messageResult,
@@ -261,9 +269,9 @@ public class ConversationListItem extends RelativeLayout
alertView.setNone();
thumbnailView.setVisibility(GONE);
setBatchState(false);
setBatchMode(false);
setRippleColor(recipient.get());
contactPhotoImage.setAvatar(glideRequests, recipient.get(), true);
contactPhotoImage.setAvatar(glideRequests, recipient.get(), !batchMode);
}
@Override
@@ -271,7 +279,9 @@ public class ConversationListItem extends RelativeLayout
if (this.recipient != null) {
this.recipient.removeForeverObserver(this);
this.recipient = null;
contactPhotoImage.setAvatar(glideRequests, null, true);
setBatchMode(false);
contactPhotoImage.setAvatar(glideRequests, null, !batchMode);
}
if (this.groupAddedBy != null) {
@@ -280,8 +290,9 @@ public class ConversationListItem extends RelativeLayout
}
}
private void setBatchState(boolean batch) {
setSelected(batch && selectedThreads.contains(threadId));
private void setBatchMode(boolean batchMode) {
this.batchMode = batchMode;
setSelected(batchMode && selectedThreads.contains(thread.getThreadId()));
}
public Recipient getRecipient() {
@@ -292,6 +303,10 @@ public class ConversationListItem extends RelativeLayout
return threadId;
}
public @NonNull ThreadRecord getThread() {
return thread;
}
public int getUnreadCount() {
return unreadCount;
}
@@ -304,7 +319,7 @@ public class ConversationListItem extends RelativeLayout
return lastSeen;
}
private @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet
: snippet.subSequence(0, MAX_SNIPPET_LENGTH);
}
@@ -316,9 +331,7 @@ public class ConversationListItem extends RelativeLayout
LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer .getLayoutParams();
subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.thumbnail);
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
subjectParams.addRule(RelativeLayout.START_OF, R.id.thumbnail);
}
subjectParams.addRule(RelativeLayout.START_OF, R.id.thumbnail);
this.subjectContainer.setLayoutParams(subjectParams);
this.post(new ThumbnailPositioner(thumbnailView, archivedView, deliveryStatusIndicator, dateView));
} else {
@@ -326,9 +339,7 @@ public class ConversationListItem extends RelativeLayout
LayoutParams subjectParams = (RelativeLayout.LayoutParams)this.subjectContainer.getLayoutParams();
subjectParams.addRule(RelativeLayout.LEFT_OF, R.id.status);
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
subjectParams.addRule(RelativeLayout.START_OF, R.id.status);
}
subjectParams.addRule(RelativeLayout.START_OF, R.id.status);
this.subjectContainer.setLayoutParams(subjectParams);
}
}
@@ -361,22 +372,109 @@ public class ConversationListItem extends RelativeLayout
}
private void setUnreadIndicator(ThreadRecord thread) {
if (thread.isOutgoing() || thread.getUnreadCount() == 0) {
if ((thread.isOutgoing() && !thread.isForcedUnread()) || thread.isRead()) {
unreadIndicator.setVisibility(View.GONE);
return;
}
unreadIndicator.setText(String.valueOf(unreadCount));
unreadIndicator.setText(unreadCount > 0 ? String.valueOf(unreadCount) : " ");
unreadIndicator.setVisibility(View.VISIBLE);
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
fromView.setText(recipient, unreadCount == 0);
contactPhotoImage.setAvatar(glideRequests, recipient, true);
contactPhotoImage.setAvatar(glideRequests, recipient, !batchMode);
setRippleColor(recipient);
}
private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
if (thread.getGroupAddedBy() != null) {
return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
: R.string.ThreadRecord_s_added_you_to_the_group,
Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context)));
} else if (!thread.isMessageRequestAccepted()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
} else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
} else if (SmsDatabase.Types.isGroupQuit(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ConversationListItem_key_exchange_message));
} else if (SmsDatabase.Types.isFailedDecryptType(thread.getType())) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
} else if (SmsDatabase.Types.isNoRemoteSessionType(thread.getType())) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
} else if (SmsDatabase.Types.isEndSessionType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset));
} else if (MmsSmsColumns.Types.isLegacyType(thread.getType())) {
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
} else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) {
String draftText = context.getString(R.string.ThreadRecord_draft);
return emphasisAdded(draftText + " " + thread.getBody(), 0, draftText.length());
} else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
} else if (SmsDatabase.Types.isIncomingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
} else if (SmsDatabase.Types.isMissedCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
} else if (SmsDatabase.Types.isJoinedType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, thread.getRecipient().toShortString(context)));
} else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) {
int seconds = (int)(thread.getExpiresIn() / 1000);
if (seconds <= 0) {
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_messages_disabled));
}
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time));
} else if (SmsDatabase.Types.isIdentityUpdate(thread.getType())) {
if (thread.getRecipient().isGroup()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
} else {
return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, thread.getRecipient().toShortString(context)));
}
} else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
} else if (SmsDatabase.Types.isIdentityDefault(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified));
} else if (SmsDatabase.Types.isUnsupportedMessageType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed));
} else {
ThreadDatabase.Extra extra = thread.getExtra();
if (extra != null && extra.isViewOnce()) {
return new SpannableString(emphasisAdded(getViewOnceDescription(context, thread.getContentType())));
} else if (extra != null && extra.isRemoteDelete()) {
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_this_message_was_deleted)));
} else {
return new SpannableString(Util.emptyIfNull(thread.getBody()));
}
}
}
private static @NonNull SpannableString emphasisAdded(String sequence) {
return emphasisAdded(sequence, 0, sequence.length());
}
private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
if (MediaUtil.isViewOnceType(contentType)) {
return context.getString(R.string.ThreadRecord_view_once_media);
} else if (MediaUtil.isVideoType(contentType)) {
return context.getString(R.string.ThreadRecord_view_once_video);
} else {
return context.getString(R.string.ThreadRecord_view_once_photo);
}
}
private static class ThumbnailPositioner implements Runnable {
private final View thumbnailView;
@@ -399,14 +497,10 @@ public class ConversationListItem extends RelativeLayout
(archivedView.getWidth() + deliveryStatusView.getWidth()) > dateView.getWidth())
{
thumbnailParams.addRule(RelativeLayout.LEFT_OF, R.id.status);
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.status);
}
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.status);
} else {
thumbnailParams.addRule(RelativeLayout.LEFT_OF, R.id.date);
if (VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR1) {
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.date);
}
thumbnailParams.addRule(RelativeLayout.START_OF, R.id.date);
}
thumbnailView.setLayoutParams(thumbnailParams);

View File

@@ -31,6 +31,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import com.bumptech.glide.Glide;
import com.fasterxml.jackson.annotation.JsonCreator;
@@ -44,12 +45,14 @@ import org.json.JSONException;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.audio.AudioHash;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
import org.thoughtcrime.securesms.crypto.ModernEncryptingPartOutputStream;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MediaStream;
import org.thoughtcrime.securesms.mms.MmsException;
@@ -120,7 +123,7 @@ public class AttachmentDatabase extends Database {
static final String HEIGHT = "height";
static final String CAPTION = "caption";
private static final String DATA_HASH = "data_hash";
static final String BLUR_HASH = "blur_hash";
static final String VISUAL_HASH = "blur_hash";
static final String TRANSFORM_PROPERTIES = "transform_properties";
static final String DISPLAY_ORDER = "display_order";
static final String UPLOAD_TIMESTAMP = "upload_timestamp";
@@ -145,7 +148,7 @@ public class AttachmentDatabase extends Database {
THUMBNAIL_ASPECT_RATIO, UNIQUE_ID, DIGEST,
FAST_PREFLIGHT_ID, VOICE_NOTE, QUOTE, DATA_RANDOM,
THUMBNAIL_RANDOM, WIDTH, HEIGHT, CAPTION, STICKER_PACK_ID,
STICKER_PACK_KEY, STICKER_ID, DATA_HASH, BLUR_HASH,
STICKER_PACK_KEY, STICKER_ID, DATA_HASH, VISUAL_HASH,
TRANSFORM_PROPERTIES, TRANSFER_FILE, DISPLAY_ORDER,
UPLOAD_TIMESTAMP };
@@ -182,7 +185,7 @@ public class AttachmentDatabase extends Database {
STICKER_PACK_KEY + " DEFAULT NULL, " +
STICKER_ID + " INTEGER DEFAULT -1, " +
DATA_HASH + " TEXT DEFAULT NULL, " +
BLUR_HASH + " TEXT DEFAULT NULL, " +
VISUAL_HASH + " TEXT DEFAULT NULL, " +
TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " +
TRANSFER_FILE + " TEXT DEFAULT NULL, " +
DISPLAY_ORDER + " INTEGER DEFAULT 0, " +
@@ -417,7 +420,7 @@ public class AttachmentDatabase extends Database {
values.put(WIDTH, 0);
values.put(HEIGHT, 0);
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
values.put(BLUR_HASH, (String) null);
values.put(VISUAL_HASH, (String) null);
values.put(CONTENT_TYPE, MediaUtil.VIEW_ONCE);
database.update(TABLE_NAME, values, MMS_ID + " = ?", new String[] {mmsId + ""});
@@ -530,8 +533,9 @@ public class AttachmentDatabase extends Database {
values.put(DATA_HASH, dataInfo.hash);
}
if (placeholder != null && placeholder.getBlurHash() != null) {
values.put(BLUR_HASH, placeholder.getBlurHash().getHash());
String visualHashString = getVisualHashStringOrNull(placeholder);
if (visualHashString != null) {
values.put(VISUAL_HASH, visualHashString);
}
values.put(TRANSFER_STATE, TRANSFER_PROGRESS_DONE);
@@ -555,9 +559,11 @@ public class AttachmentDatabase extends Database {
thumbnailExecutor.submit(new ThumbnailFetchCallable(attachmentId, STANDARD_THUMB_TIME));
}
private static @Nullable String getBlurHashStringOrNull(@Nullable BlurHash blurHash) {
if (blurHash == null) return null;
return blurHash.getHash();
private static @Nullable String getVisualHashStringOrNull(@Nullable Attachment attachment) {
if (attachment == null) return null;
else if (attachment.getBlurHash() != null) return attachment.getBlurHash().getHash();
else if (attachment.getAudioHash() != null) return attachment.getAudioHash().getHash();
else return null;
}
public void copyAttachmentData(@NonNull AttachmentId sourceId, @NonNull AttachmentId destinationId)
@@ -594,7 +600,7 @@ public class AttachmentDatabase extends Database {
contentValues.put(WIDTH, sourceAttachment.getWidth());
contentValues.put(HEIGHT, sourceAttachment.getHeight());
contentValues.put(CONTENT_TYPE, sourceAttachment.getContentType());
contentValues.put(BLUR_HASH, getBlurHashStringOrNull(sourceAttachment.getBlurHash()));
contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(sourceAttachment));
database.update(TABLE_NAME, contentValues, PART_ID_WHERE, destinationId.toStrings());
}
@@ -638,7 +644,7 @@ public class AttachmentDatabase extends Database {
values.put(NAME, attachment.getRelay());
values.put(SIZE, attachment.getSize());
values.put(FAST_PREFLIGHT_ID, attachment.getFastPreflightId());
values.put(BLUR_HASH, getBlurHashStringOrNull(attachment.getBlurHash()));
values.put(VISUAL_HASH, getVisualHashStringOrNull(attachment));
values.put(UPLOAD_TIMESTAMP, uploadTimestamp);
if (dataInfo != null && dataInfo.hash != null) {
@@ -1099,11 +1105,12 @@ public class AttachmentDatabase extends Database {
JsonUtils.SaneJSONObject object = new JsonUtils.SaneJSONObject(array.getJSONObject(i));
if (!object.isNull(ROW_ID)) {
String contentType = object.getString(CONTENT_TYPE);
result.add(new DatabaseAttachment(new AttachmentId(object.getLong(ROW_ID), object.getLong(UNIQUE_ID)),
object.getLong(MMS_ID),
!TextUtils.isEmpty(object.getString(DATA)),
!TextUtils.isEmpty(object.getString(THUMBNAIL)),
object.getString(CONTENT_TYPE),
contentType,
object.getInt(TRANSFER_STATE),
object.getLong(SIZE),
object.getString(FILE_NAME),
@@ -1123,7 +1130,8 @@ public class AttachmentDatabase extends Database {
object.getString(STICKER_PACK_KEY),
object.getInt(STICKER_ID))
: null,
BlurHash.parseOrNull(object.getString(BLUR_HASH)),
MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(object.getString(VISUAL_HASH)),
MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(object.getString(VISUAL_HASH)) : null,
TransformProperties.parse(object.getString(TRANSFORM_PROPERTIES)),
object.getInt(DISPLAY_ORDER),
object.getLong(UPLOAD_TIMESTAMP)));
@@ -1132,12 +1140,13 @@ public class AttachmentDatabase extends Database {
return result;
} else {
String contentType = cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE));
return Collections.singletonList(new DatabaseAttachment(new AttachmentId(cursor.getLong(cursor.getColumnIndexOrThrow(ROW_ID)),
cursor.getLong(cursor.getColumnIndexOrThrow(UNIQUE_ID))),
cursor.getLong(cursor.getColumnIndexOrThrow(MMS_ID)),
!cursor.isNull(cursor.getColumnIndexOrThrow(DATA)),
!cursor.isNull(cursor.getColumnIndexOrThrow(THUMBNAIL)),
cursor.getString(cursor.getColumnIndexOrThrow(CONTENT_TYPE)),
contentType,
cursor.getInt(cursor.getColumnIndexOrThrow(TRANSFER_STATE)),
cursor.getLong(cursor.getColumnIndexOrThrow(SIZE)),
cursor.getString(cursor.getColumnIndexOrThrow(FILE_NAME)),
@@ -1157,7 +1166,8 @@ public class AttachmentDatabase extends Database {
cursor.getString(cursor.getColumnIndexOrThrow(STICKER_PACK_KEY)),
cursor.getInt(cursor.getColumnIndexOrThrow(STICKER_ID)))
: null,
BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(BLUR_HASH))),
MediaUtil.isAudioType(contentType) ? null : BlurHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))),
MediaUtil.isAudioType(contentType) ? AudioHash.parseOrNull(cursor.getString(cursor.getColumnIndexOrThrow(VISUAL_HASH))) : null,
TransformProperties.parse(cursor.getString(cursor.getColumnIndexOrThrow(TRANSFORM_PROPERTIES))),
cursor.getInt(cursor.getColumnIndexOrThrow(DISPLAY_ORDER)),
cursor.getLong(cursor.getColumnIndexOrThrow(UPLOAD_TIMESTAMP))));
@@ -1167,7 +1177,6 @@ public class AttachmentDatabase extends Database {
}
}
private AttachmentId insertAttachment(long mmsId, Attachment attachment, boolean quote)
throws MmsException
{
@@ -1219,11 +1228,11 @@ public class AttachmentDatabase extends Database {
contentValues.put(CAPTION, attachment.getCaption());
contentValues.put(UPLOAD_TIMESTAMP, useTemplateUpload ? template.getUploadTimestamp() : attachment.getUploadTimestamp());
if (attachment.getTransformProperties().isVideoEdited()) {
contentValues.putNull(BLUR_HASH);
contentValues.putNull(VISUAL_HASH);
contentValues.put(TRANSFORM_PROPERTIES, attachment.getTransformProperties().serialize());
thumbnailTimeUs = Math.max(STANDARD_THUMB_TIME, attachment.getTransformProperties().videoTrimStartTimeUs);
} else {
contentValues.put(BLUR_HASH, getBlurHashStringOrNull(template.getBlurHash()));
contentValues.put(VISUAL_HASH, getVisualHashStringOrNull(template));
contentValues.put(TRANSFORM_PROPERTIES, template.getTransformProperties().serialize());
thumbnailTimeUs = STANDARD_THUMB_TIME;
}
@@ -1330,6 +1339,21 @@ public class AttachmentDatabase extends Database {
}
}
@WorkerThread
public void writeAudioHash(@NonNull AttachmentId attachmentId, @Nullable AudioWaveFormData audioWaveForm) {
Log.i(TAG, "updating part audio wave form for #" + attachmentId);
SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(1);
if (audioWaveForm != null) {
values.put(VISUAL_HASH, new AudioHash(audioWaveForm).getHash());
} else {
values.putNull(VISUAL_HASH);
}
database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
}
@VisibleForTesting
class ThumbnailFetchCallable implements Callable<InputStream> {

View File

@@ -31,6 +31,8 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder;
import org.thoughtcrime.securesms.R;
import java.util.List;
/**
* RecyclerView.Adapter that manages a Cursor, comparable to the CursorAdapter usable in ListView/GridView.
*/
@@ -173,6 +175,16 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
public abstract VH onCreateItemViewHolder(ViewGroup parent, int viewType);
@Override
public final void onBindViewHolder(@NonNull ViewHolder viewHolder, int position, @NonNull List<Object> payloads) {
if (arePayloadsValid(payloads) && !isHeaderPosition(position) && !isFooterPosition(position)) {
if (isFastAccessPosition(position)) onBindFastAccessItemViewHolder((VH)viewHolder, position, payloads);
else onBindItemViewHolder((VH)viewHolder, getCursorAtPositionOrThrow(position), payloads);
} else {
onBindViewHolder(viewHolder, position);
}
}
@SuppressWarnings("unchecked")
@Override
public final void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) {
@@ -188,8 +200,17 @@ public abstract class CursorRecyclerViewAdapter<VH extends RecyclerView.ViewHold
public abstract void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor);
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
protected boolean arePayloadsValid(@NonNull List<Object> payloads) {
return false;
}
protected void onBindItemViewHolder(VH viewHolder, @NonNull Cursor cursor, @NonNull List<Object> payloads) {
}
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position) {
}
protected void onBindFastAccessItemViewHolder(VH viewHolder, int position, @NonNull List<Object> payloads) {
}
@Override

View File

@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.Closeable;
@@ -41,6 +40,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
public final class GroupDatabase extends Database {
@@ -64,7 +64,7 @@ public final class GroupDatabase extends Database {
/* V2 Group columns */
/** 32 bytes serialized {@link GroupMasterKey} */
private static final String V2_MASTER_KEY = "master_key";
public static final String V2_MASTER_KEY = "master_key";
/** Increments with every change to the group */
private static final String V2_REVISION = "revision";
/** Serialized {@link DecryptedGroup} protobuf */
@@ -252,7 +252,7 @@ public final class GroupDatabase extends Database {
@WorkerThread
public @NonNull List<Recipient> getGroupMembers(@NonNull GroupId groupId, @NonNull MemberSet memberSet) {
if (groupId.isV2()) {
return getGroup(groupId).transform(g -> g.requireV2GroupProperties().getMembers(context, memberSet))
return getGroup(groupId).transform(g -> g.requireV2GroupProperties().getMemberRecipients(memberSet))
.or(Collections.emptyList());
} else {
List<RecipientId> currentMembers = getCurrentMembers(groupId);
@@ -336,9 +336,9 @@ public final class GroupDatabase extends Database {
}
groupId.requireV2();
contentValues.put(V2_MASTER_KEY, groupMasterKey.serialize());
contentValues.put(V2_REVISION, groupState.getVersion());
contentValues.put(V2_REVISION, groupState.getRevision());
contentValues.put(V2_DECRYPTED_GROUP, groupState.toByteArray());
contentValues.put(MEMBERS, serializeV2GroupMembers(context, groupState));
contentValues.put(MEMBERS, serializeV2GroupMembers(groupState));
} else {
if (groupId.isV2()) {
throw new AssertionError("V2 group id but no master key");
@@ -391,11 +391,14 @@ public final class GroupDatabase extends Database {
RecipientId groupRecipientId = recipientDatabase.getOrInsertFromGroupId(groupId);
String title = decryptedGroup.getTitle();
ContentValues contentValues = new ContentValues();
UUID uuid = Recipient.self().getUuid().get();
contentValues.put(TITLE, title);
contentValues.put(V2_REVISION, decryptedGroup.getVersion());
contentValues.put(V2_REVISION, decryptedGroup.getRevision());
contentValues.put(V2_DECRYPTED_GROUP, decryptedGroup.toByteArray());
contentValues.put(MEMBERS, serializeV2GroupMembers(context, decryptedGroup));
contentValues.put(MEMBERS, serializeV2GroupMembers(decryptedGroup));
contentValues.put(ACTIVE, DecryptedGroupUtil.findMemberByUuid(decryptedGroup.getMembersList(), uuid).isPresent() ||
DecryptedGroupUtil.findPendingByUuid(decryptedGroup.getPendingMembersList(), uuid).isPresent() ? 1 : 0);
databaseHelper.getWritableDatabase().update(TABLE_NAME, contentValues,
GROUP_ID + " = ?",
@@ -517,13 +520,16 @@ public final class GroupDatabase extends Database {
return getGroup(groupId).transform(g -> g.isPendingMember(recipient)).or(false);
}
private static String serializeV2GroupMembers(@NonNull Context context, @NonNull DecryptedGroup decryptedGroup) {
private static String serializeV2GroupMembers(@NonNull DecryptedGroup decryptedGroup) {
List<RecipientId> groupMembers = new ArrayList<>(decryptedGroup.getMembersCount());
for (DecryptedMember member : decryptedGroup.getMembersList()) {
Recipient recipient = Recipient.externalPush(context, new SignalServiceAddress(UuidUtil.fromByteString(member.getUuid()), null));
groupMembers.add(recipient.getId());
UUID uuid = UuidUtil.fromByteString(member.getUuid());
if (UuidUtil.UNKNOWN_UUID.equals(uuid)) {
Log.w(TAG, "Seen unknown UUID in members list");
} else {
groupMembers.add(RecipientId.from(uuid, null));
}
}
Collections.sort(groupMembers);
@@ -779,25 +785,41 @@ public final class GroupDatabase extends Database {
.or(false);
}
public List<Recipient> getMembers(@NonNull Context context, @NonNull MemberSet memberSet) {
boolean includeSelf = memberSet.includeSelf;
DecryptedGroup groupV2 = getDecryptedGroup();
UUID selfUuid = Recipient.self().getUuid().get();
List<Recipient> recipients = new ArrayList<>(groupV2.getMembersCount() + groupV2.getPendingMembersCount());
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
return Stream.of(getMemberRecipientIds(memberSet))
.map(Recipient::resolved)
.toList();
}
public List<RecipientId> getMemberRecipientIds(@NonNull MemberSet memberSet) {
boolean includeSelf = memberSet.includeSelf;
DecryptedGroup groupV2 = getDecryptedGroup();
UUID selfUuid = Recipient.self().getUuid().get();
List<RecipientId> recipients = new ArrayList<>(groupV2.getMembersCount() + groupV2.getPendingMembersCount());
int unknownMembers = 0;
int unknownPending = 0;
for (UUID uuid : DecryptedGroupUtil.toUuidList(groupV2.getMembersList())) {
if (includeSelf || !selfUuid.equals(uuid)) {
recipients.add(Recipient.externalPush(context, uuid, null));
if (UuidUtil.UNKNOWN_UUID.equals(uuid)) {
unknownMembers++;
} else if (includeSelf || !selfUuid.equals(uuid)) {
recipients.add(RecipientId.from(uuid, null));
}
}
if (memberSet.includePending) {
for (UUID uuid : DecryptedGroupUtil.pendingToUuidList(groupV2.getPendingMembersList())) {
if (includeSelf || !selfUuid.equals(uuid)) {
recipients.add(Recipient.externalPush(context, uuid, null));
if (UuidUtil.UNKNOWN_UUID.equals(uuid)) {
unknownPending++;
} else if (includeSelf || !selfUuid.equals(uuid)) {
recipients.add(RecipientId.from(uuid, null));
}
}
}
if ((unknownMembers + unknownPending) > 0) {
Log.w(TAG, String.format(Locale.US, "Group contains %d + %d unknown pending and full members", unknownPending, unknownMembers));
}
return recipients;
}
}

View File

@@ -11,6 +11,7 @@ import net.sqlcipher.database.SQLiteDatabase;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.Pair;
import java.util.Collection;
import java.util.LinkedList;
@@ -67,14 +68,24 @@ public class GroupReceiptDatabase extends Database {
new String[] {String.valueOf(mmsId), recipientId.serialize(), String.valueOf(status)});
}
public void setUnidentified(RecipientId recipientId, long mmsId, boolean unidentified) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(1);
values.put(UNIDENTIFIED, unidentified ? 1 : 0);
public void setUnidentified(Collection<Pair<RecipientId, Boolean>> results, long mmsId) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, values, MMS_ID + " = ? AND " + RECIPIENT_ID + " = ?",
new String[] {String.valueOf(mmsId), recipientId.serialize()});
db.beginTransaction();
try {
String query = MMS_ID + " = ? AND " + RECIPIENT_ID + " = ?";
for (Pair<RecipientId, Boolean> result : results) {
ContentValues values = new ContentValues(1);
values.put(UNIDENTIFIED, result.second() ? 1 : 0);
db.update(TABLE_NAME, values, query, new String[]{ String.valueOf(mmsId), result.first().serialize()});
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
}
public @NonNull List<GroupReceiptInfo> getGroupReceiptInfo(long mmsId) {

View File

@@ -146,6 +146,7 @@ public class IdentityDatabase extends Database {
}
public void updateIdentityAfterSync(@NonNull RecipientId id, IdentityKey identityKey, VerifiedStatus verifiedStatus) {
boolean hadEntry = getIdentity(id).isPresent();
boolean keyMatches = hasMatchingKey(id, identityKey);
boolean statusMatches = keyMatches && hasMatchingStatus(id, identityKey, verifiedStatus);
@@ -155,7 +156,7 @@ public class IdentityDatabase extends Database {
if (record.isPresent()) EventBus.getDefault().post(record.get());
}
if (!keyMatches) {
if (hadEntry && !keyMatches) {
IdentityUtil.markIdentityUpdate(context, id);
}
}

View File

@@ -43,7 +43,7 @@ public class MediaDatabase extends Database {
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", "
+ AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.CAPTION + ", "

View File

@@ -218,7 +218,7 @@ public class MmsDatabase extends MessagingDatabase {
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID+ ", " +
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " +
"'" + AttachmentDatabase.BLUR_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", " +
"'" + AttachmentDatabase.VISUAL_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", " +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " +
"'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", " +
"'" + AttachmentDatabase.UPLOAD_TIMESTAMP + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UPLOAD_TIMESTAMP +
@@ -1083,9 +1083,7 @@ public class MmsDatabase extends MessagingDatabase {
if (message.isGroup()) {
OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (OutgoingGroupUpdateMessage) message;
if (outgoingGroupUpdateMessage.isV2Group()) {
MessageGroupContext.GroupV2Properties groupV2Properties = outgoingGroupUpdateMessage.requireGroupV2Properties();
type |= Types.GROUP_V2_BIT;
if (groupV2Properties.isUpdate()) type |= Types.GROUP_UPDATE_BIT;
type |= Types.GROUP_V2_BIT | Types.GROUP_UPDATE_BIT;
} else {
MessageGroupContext.GroupV1Properties properties = outgoingGroupUpdateMessage.requireGroupV1Properties();
if (properties.isUpdate()) type |= Types.GROUP_UPDATE_BIT;
@@ -1129,20 +1127,15 @@ public class MmsDatabase extends MessagingDatabase {
if (message.getRecipient().isGroup()) {
OutgoingGroupUpdateMessage outgoingGroupUpdateMessage = (message instanceof OutgoingGroupUpdateMessage) ? (OutgoingGroupUpdateMessage) message : null;
GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context);
RecipientDatabase recipientDatabase = DatabaseFactory.getRecipientDatabase(context);
Set<RecipientId> members = new HashSet<>();
GroupReceiptDatabase receiptDatabase = DatabaseFactory.getGroupReceiptDatabase(context);
Set<RecipientId> members = new HashSet<>();
if (outgoingGroupUpdateMessage != null && outgoingGroupUpdateMessage.isV2Group()) {
MessageGroupContext.GroupV2Properties groupV2Properties = outgoingGroupUpdateMessage.requireGroupV2Properties();
members.addAll(Stream.of(groupV2Properties.getActiveMembers()).map(recipientDatabase::getOrInsertFromUuid).toList());
if (groupV2Properties.isUpdate()) {
members.addAll(Stream.concat(Stream.of(groupV2Properties.getPendingMembers()),
Stream.of(groupV2Properties.getRemovedMembers()))
.distinct()
.map(recipientDatabase::getOrInsertFromUuid)
.toList());
}
members.addAll(Stream.of(groupV2Properties.getAllActivePendingAndRemovedMembers())
.distinct()
.map(uuid -> RecipientId.from(uuid, null))
.toList());
members.remove(Recipient.self().getId());
} else {
members.addAll(Stream.of(DatabaseFactory.getGroupDatabase(context).getGroupMembers(message.getRecipient().requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF)).map(Recipient::getId).toList());

View File

@@ -28,7 +28,6 @@ import net.sqlcipher.database.SQLiteQueryBuilder;
import org.thoughtcrime.securesms.database.MessagingDatabase.SyncMessageId;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.Pair;
@@ -387,7 +386,7 @@ public class MmsSmsDatabase extends Database {
"'" + AttachmentDatabase.STICKER_PACK_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_ID + ", " +
"'" + AttachmentDatabase.STICKER_PACK_KEY + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_PACK_KEY + ", " +
"'" + AttachmentDatabase.STICKER_ID + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.STICKER_ID + ", " +
"'" + AttachmentDatabase.BLUR_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.BLUR_HASH + ", " +
"'" + AttachmentDatabase.VISUAL_HASH + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.VISUAL_HASH + ", " +
"'" + AttachmentDatabase.TRANSFORM_PROPERTIES + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.TRANSFORM_PROPERTIES + ", " +
"'" + AttachmentDatabase.DISPLAY_ORDER + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.DISPLAY_ORDER + ", " +
"'" + AttachmentDatabase.UPLOAD_TIMESTAMP + "', " + AttachmentDatabase.TABLE_NAME + "." + AttachmentDatabase.UPLOAD_TIMESTAMP +

View File

@@ -50,7 +50,7 @@ public class PushDatabase extends Database {
Optional<Long> messageId = find(envelope);
if (messageId.isPresent()) {
return messageId.get();
return -1;
} else {
ContentValues values = new ContentValues();
values.put(TYPE, envelope.getType());

View File

@@ -14,6 +14,8 @@ import com.google.android.gms.common.util.ArrayUtils;
import net.sqlcipher.database.SQLiteDatabase;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.profiles.ProfileKey;
import org.signal.zkgroup.profiles.ProfileKeyCredential;
import org.thoughtcrime.securesms.color.MaterialColor;
@@ -23,6 +25,7 @@ import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet;
import org.thoughtcrime.securesms.jobs.WakeGroupV2Job;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.profiles.ProfileName;
@@ -32,6 +35,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.storage.StorageSyncHelper.RecordUpdate;
import org.thoughtcrime.securesms.storage.StorageSyncModels;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.SqlUtil;
import org.thoughtcrime.securesms.util.Util;
@@ -43,6 +47,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.storage.SignalAccountRecord;
import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.UuidUtil;
@@ -124,27 +129,26 @@ public class RecipientDatabase extends Database {
STORAGE_SERVICE_ID, DIRTY
};
private static final String[] ID_PROJECTION = new String[]{ID};
private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME};
public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, SEARCH_PROFILE_NAME, SORT_NAME};
static final String[] TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
.map(columnName -> TABLE_NAME + "." + columnName)
.toList().toArray(new String[0]);
private static final String[] RECIPIENT_FULL_PROJECTION = ArrayUtils.concat(
new String[] { TABLE_NAME + "." + ID },
RECIPIENT_PROJECTION,
TYPED_RECIPIENT_PROJECTION,
new String[] {
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.VERIFIED + " AS " + IDENTITY_STATUS,
IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.IDENTITY_KEY + " AS " + IDENTITY_KEY
});
public static final String[] CREATE_INDEXS = new String[] {
"CREATE INDEX IF NOT EXISTS recipient_dirty_index ON " + TABLE_NAME + " (" + DIRTY + ");",
"CREATE INDEX IF NOT EXISTS recipient_group_type_index ON " + TABLE_NAME + " (" + GROUP_TYPE + ");",
};
private static final String[] ID_PROJECTION = new String[]{ID};
private static final String[] SEARCH_PROJECTION = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, "COALESCE(" + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ") AS " + SEARCH_PROFILE_NAME, "COALESCE(" + nullIfEmpty(SYSTEM_DISPLAY_NAME) + ", " + nullIfEmpty(PROFILE_JOINED_NAME) + ", " + nullIfEmpty(PROFILE_GIVEN_NAME) + ", " + nullIfEmpty(USERNAME) + ") AS " + SORT_NAME};
public static final String[] SEARCH_PROJECTION_NAMES = new String[]{ID, SYSTEM_DISPLAY_NAME, PHONE, EMAIL, SYSTEM_PHONE_LABEL, SYSTEM_PHONE_TYPE, REGISTERED, SEARCH_PROFILE_NAME, SORT_NAME};
static final List<String> TYPED_RECIPIENT_PROJECTION = Stream.of(RECIPIENT_PROJECTION)
.map(columnName -> TABLE_NAME + "." + columnName)
.toList();
public enum VibrateState {
DEFAULT(0), ENABLED(1), DISABLED(2);
@@ -240,7 +244,7 @@ public class RecipientDatabase extends Database {
}
public enum GroupType {
NONE(0), MMS(1), SIGNAL_V1(2);
NONE(0), MMS(1), SIGNAL_V1(2), SIGNAL_V2(3);
private final int id;
@@ -365,7 +369,11 @@ public class RecipientDatabase extends Database {
if (groupId.isMms()) {
values.put(GROUP_TYPE, GroupType.MMS.getId());
} else {
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
if (groupId.isV2()) {
values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId());
} else {
values.put(GROUP_TYPE, GroupType.SIGNAL_V1.getId());
}
values.put(DIRTY, DirtyState.INSERT.getId());
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(StorageSyncHelper.generateKey()));
}
@@ -422,29 +430,46 @@ public class RecipientDatabase extends Database {
return DirtyState.CLEAN;
}
public @Nullable RecipientSettings getRecipientSettingsForSync(@NonNull RecipientId id) {
String query = TABLE_NAME + "." + ID + " = ?";
String[] args = new String[]{id.serialize()};
List<RecipientSettings> recipientSettingsForSync = getRecipientSettingsForSync(query, args);
if (recipientSettingsForSync.isEmpty()) {
return null;
}
if (recipientSettingsForSync.size() > 1) {
throw new AssertionError();
}
return recipientSettingsForSync.get(0);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncUpdates() {
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.UPDATE.getId()), Recipient.self().getId().serialize() };
return getRecipientSettings(query, args);
return getRecipientSettingsForSync(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncInsertions() {
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.INSERT.getId()), Recipient.self().getId().serialize() };
return getRecipientSettings(query, args);
return getRecipientSettingsForSync(query, args);
}
public @NonNull List<RecipientSettings> getPendingRecipientSyncDeletions() {
String query = DIRTY + " = ? AND " + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String query = TABLE_NAME + "." + DIRTY + " = ? AND " + TABLE_NAME + "." + STORAGE_SERVICE_ID + " NOT NULL AND " + TABLE_NAME + "." + ID + " != ?";
String[] args = new String[] { String.valueOf(DirtyState.DELETE.getId()), Recipient.self().getId().serialize() };
return getRecipientSettings(query, args);
return getRecipientSettingsForSync(query, args);
}
public @Nullable RecipientSettings getByStorageId(@NonNull byte[] storageId) {
List<RecipientSettings> result = getRecipientSettings(STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) });
List<RecipientSettings> result = getRecipientSettingsForSync(TABLE_NAME + "." + STORAGE_SERVICE_ID + " = ?", new String[] { Base64.encodeBytes(storageId) });
if (result.size() > 0) {
return result.get(0);
@@ -480,7 +505,9 @@ public class RecipientDatabase extends Database {
public void applyStorageSyncUpdates(@NonNull Collection<SignalContactRecord> contactInserts,
@NonNull Collection<RecordUpdate<SignalContactRecord>> contactUpdates,
@NonNull Collection<SignalGroupV1Record> groupV1Inserts,
@NonNull Collection<RecordUpdate<SignalGroupV1Record>> groupV1Updates)
@NonNull Collection<RecordUpdate<SignalGroupV1Record>> groupV1Updates,
@NonNull Collection<SignalGroupV2Record> groupV2Inserts,
@NonNull Collection<RecordUpdate<SignalGroupV2Record>> groupV2Updates)
{
SQLiteDatabase db = databaseHelper.getWritableDatabase();
IdentityDatabase identityDatabase = DatabaseFactory.getIdentityDatabase(context);
@@ -491,7 +518,7 @@ public class RecipientDatabase extends Database {
try {
for (SignalContactRecord insert : contactInserts) {
ContentValues values = getValuesForStorageContact(insert);
ContentValues values = validateContactValuesForInsert(getValuesForStorageContact(insert));
long id = db.insertWithOnConflict(TABLE_NAME, null, values, SQLiteDatabase.CONFLICT_IGNORE);
if (id < 0) {
@@ -512,7 +539,6 @@ public class RecipientDatabase extends Database {
IdentityKey identityKey = new IdentityKey(insert.getIdentityKey().get(), 0);
DatabaseFactory.getIdentityDatabase(context).updateIdentityAfterSync(recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.getIdentityState()));
IdentityUtil.markIdentityVerified(context, Recipient.resolved(recipientId), true, true);
} catch (InvalidKeyException e) {
Log.w(TAG, "Failed to process identity key during insert! Skipping.", e);
}
@@ -586,6 +612,32 @@ public class RecipientDatabase extends Database {
threadDatabase.setArchived(recipient.getId(), update.getNew().isArchived());
recipient.live().refresh();
}
for (SignalGroupV2Record insert : groupV2Inserts) {
db.insertOrThrow(TABLE_NAME, null, getValuesForStorageGroupV2(insert));
GroupId.V2 groupId = GroupId.v2(insert.getMasterKey());
Recipient recipient = Recipient.externalGroup(context, groupId);
ApplicationDependencies.getJobManager().add(new WakeGroupV2Job(insert.getMasterKey()));
threadDatabase.setArchived(recipient.getId(), insert.isArchived());
recipient.live().refresh();
}
for (RecordUpdate<SignalGroupV2Record> update : groupV2Updates) {
ContentValues values = getValuesForStorageGroupV2(update.getNew());
int updateCount = db.update(TABLE_NAME, values, STORAGE_SERVICE_ID + " = ?", new String[]{Base64.encodeBytes(update.getOld().getId().getRaw())});
if (updateCount < 1) {
throw new AssertionError("Had an update, but it didn't match any rows!");
}
Recipient recipient = Recipient.externalGroup(context, GroupId.v2(update.getOld().getMasterKey()));
threadDatabase.setArchived(recipient.getId(), update.getNew().isArchived());
recipient.live().refresh();
}
db.setTransactionSuccessful();
} finally {
@@ -596,13 +648,18 @@ public class RecipientDatabase extends Database {
public void applyStorageSyncUpdates(@NonNull StorageId storageId, SignalAccountRecord update) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues();
ProfileName profileName = ProfileName.fromParts(update.getGivenName().orNull(), update.getFamilyName().orNull());
ContentValues values = new ContentValues();
ProfileName profileName = ProfileName.fromParts(update.getGivenName().orNull(), update.getFamilyName().orNull());
String profileKey = update.getProfileKey().or(Optional.fromNullable(Recipient.self().getProfileKey())).transform(Base64::encodeBytes).orNull();
if (!update.getProfileKey().isPresent()) {
Log.w(TAG, "Got an empty profile key while applying an account record update!");
}
values.put(PROFILE_GIVEN_NAME, profileName.getGivenName());
values.put(PROFILE_FAMILY_NAME, profileName.getFamilyName());
values.put(PROFILE_JOINED_NAME, profileName.toString());
values.put(PROFILE_KEY, update.getProfileKey().transform(Base64::encodeBytes).orNull());
values.put(PROFILE_KEY, profileKey);
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(update.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
@@ -684,13 +741,28 @@ public class RecipientDatabase extends Database {
values.put(DIRTY, DirtyState.CLEAN.getId());
return values;
}
private static @NonNull ContentValues getValuesForStorageGroupV2(@NonNull SignalGroupV2Record groupV2) {
ContentValues values = new ContentValues();
values.put(GROUP_ID, GroupId.v2(groupV2.getMasterKey()).toString());
values.put(GROUP_TYPE, GroupType.SIGNAL_V2.getId());
values.put(PROFILE_SHARING, groupV2.isProfileSharingEnabled() ? "1" : "0");
values.put(BLOCKED, groupV2.isBlocked() ? "1" : "0");
values.put(STORAGE_SERVICE_ID, Base64.encodeBytes(groupV2.getId().getRaw()));
values.put(DIRTY, DirtyState.CLEAN.getId());
return values;
}
private List<RecipientSettings> getRecipientSettings(@Nullable String query, @Nullable String[] args) {
private List<RecipientSettings> getRecipientSettingsForSync(@Nullable String query, @Nullable String[] args) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID;
String table = TABLE_NAME + " LEFT OUTER JOIN " + IdentityDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + ID + " = " + IdentityDatabase.TABLE_NAME + "." + IdentityDatabase.RECIPIENT_ID
+ " LEFT OUTER JOIN " + GroupDatabase.TABLE_NAME + " ON " + TABLE_NAME + "." + GROUP_ID + " = " + GroupDatabase.TABLE_NAME + "." + GroupDatabase.GROUP_ID;
List<RecipientSettings> out = new ArrayList<>();
try (Cursor cursor = db.query(table, RECIPIENT_FULL_PROJECTION, query, args, null, null, null)) {
String[] columns = ArrayUtils.concat(RECIPIENT_FULL_PROJECTION,
new String[]{GroupDatabase.TABLE_NAME + "." + GroupDatabase.V2_MASTER_KEY });
try (Cursor cursor = db.query(table, columns, query, args, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
out.add(getRecipientSettings(context, cursor));
}
@@ -722,10 +794,11 @@ public class RecipientDatabase extends Database {
GroupType groupType = GroupType.fromId(cursor.getInt(cursor.getColumnIndexOrThrow(GROUP_TYPE)));
byte[] key = Base64.decodeOrThrow(encodedKey);
if (groupType == GroupType.NONE) {
out.put(id, StorageId.forContact(key));
} else {
out.put(id, StorageId.forGroupV1(key));
switch (groupType) {
case NONE : out.put(id, StorageId.forContact(key)); break;
case SIGNAL_V1 : out.put(id, StorageId.forGroupV1(key)); break;
case SIGNAL_V2 : out.put(id, StorageId.forGroupV2(key)); break;
default : throw new AssertionError();
}
}
}
@@ -771,6 +844,19 @@ public class RecipientDatabase extends Database {
String identityKeyRaw = cursor.getString(cursor.getColumnIndexOrThrow(IDENTITY_KEY));
int identityStatusRaw = cursor.getInt(cursor.getColumnIndexOrThrow(IDENTITY_STATUS));
int masterKeyIndex = cursor.getColumnIndex(GroupDatabase.V2_MASTER_KEY);
GroupMasterKey groupMasterKey = null;
try {
if (masterKeyIndex != -1) {
byte[] blob = cursor.getBlob(masterKeyIndex);
if (blob != null) {
groupMasterKey = new GroupMasterKey(blob);
}
}
} catch (InvalidInputException e) {
throw new AssertionError(e);
}
MaterialColor color;
byte[] profileKey = null;
byte[] profileKeyCredential = null;
@@ -805,7 +891,7 @@ public class RecipientDatabase extends Database {
IdentityDatabase.VerifiedStatus identityStatus = IdentityDatabase.VerifiedStatus.forState(identityStatusRaw);
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, GroupType.fromId(groupType), blocked, muteUntil,
return new RecipientSettings(RecipientId.from(id), uuid, username, e164, email, groupId, groupMasterKey, GroupType.fromId(groupType), blocked, muteUntil,
VibrateState.fromId(messageVibrateState),
VibrateState.fromId(callVibrateState),
Util.uri(messageRingtone), Util.uri(callRingtone),
@@ -1356,22 +1442,20 @@ public class RecipientDatabase extends Database {
Stream.of(updates.entrySet()).forEach(entry -> Recipient.live(entry.getKey()).refresh());
}
}
public @Nullable Cursor getSignalContacts() {
return getSignalContacts(true);
}
public @Nullable Cursor getSignalContacts(boolean includeSelf) {
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
"(" + SORT_NAME + " NOT NULL OR " + USERNAME + " NOT NULL)";
String[] args;
if (includeSelf) {
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()) };
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1" };
} else {
selection += " AND " + ID + " != ?";
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), String.valueOf(Recipient.self().getId().toLong()) };
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", Recipient.self().getId().serialize() };
}
String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + USERNAME + ", " + PHONE;
@@ -1386,6 +1470,7 @@ public class RecipientDatabase extends Database {
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
GROUP_ID + " IS NULL AND " +
"(" + SYSTEM_DISPLAY_NAME + " NOT NULL OR " + PROFILE_SHARING + " = ?) AND " +
"(" +
PHONE + " LIKE ? OR " +
SORT_NAME + " LIKE ? OR " +
@@ -1394,10 +1479,10 @@ public class RecipientDatabase extends Database {
String[] args;
if (includeSelf) {
args = new String[]{"0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query};
args = new String[]{"0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query};
} else {
selection += " AND " + ID + " != ?";
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), query, query, query, String.valueOf(Recipient.self().getId().toLong()) };
args = new String[] { "0", String.valueOf(RegisteredState.REGISTERED.getId()), "1", query, query, query, String.valueOf(Recipient.self().getId().toLong()) };
}
String orderBy = SORT_NAME + ", " + SYSTEM_DISPLAY_NAME + ", " + SEARCH_PROFILE_NAME + ", " + PHONE;
@@ -1644,6 +1729,17 @@ public class RecipientDatabase extends Database {
}
}
private static ContentValues validateContactValuesForInsert(ContentValues values) {
if (!FeatureFlags.uuids() &&
values.getAsString(UUID) != null &&
values.getAsString(PHONE) == null)
{
throw new UuidRecipientError();
} else {
return values;
}
}
public class BulkOperationsHandle {
private final SQLiteDatabase database;
@@ -1734,6 +1830,7 @@ public class RecipientDatabase extends Database {
private final String e164;
private final String email;
private final GroupId groupId;
private final GroupMasterKey groupMasterKey;
private final GroupType groupType;
private final boolean blocked;
private final long muteUntil;
@@ -1771,6 +1868,7 @@ public class RecipientDatabase extends Database {
@Nullable String e164,
@Nullable String email,
@Nullable GroupId groupId,
@Nullable GroupMasterKey groupMasterKey,
@NonNull GroupType groupType,
boolean blocked,
long muteUntil,
@@ -1808,6 +1906,7 @@ public class RecipientDatabase extends Database {
this.e164 = e164;
this.email = email;
this.groupId = groupId;
this.groupMasterKey = groupMasterKey;
this.groupType = groupType;
this.blocked = blocked;
this.muteUntil = muteUntil;
@@ -1864,6 +1963,13 @@ public class RecipientDatabase extends Database {
return groupId;
}
/**
* Only read populated for sync.
*/
public @Nullable GroupMasterKey getGroupMasterKey() {
return groupMasterKey;
}
public @NonNull GroupType getGroupType() {
return groupType;
}
@@ -2041,4 +2147,7 @@ public class RecipientDatabase extends Database {
this.neededInsert = neededInsert;
}
}
private static class UuidRecipientError extends AssertionError {
}
}

View File

@@ -0,0 +1,84 @@
package org.thoughtcrime.securesms.database;
import android.content.Context;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiStrings;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GifSlide;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
public final class ThreadBodyUtil {
private static final String TAG = Log.tag(ThreadBodyUtil.class);
private ThreadBodyUtil() {
}
public static @NonNull String getFormattedBodyFor(@NonNull Context context, @NonNull MessageRecord record) {
if (record.isMms()) {
return getFormattedBodyForMms(context, (MmsMessageRecord) record);
}
return record.getBody();
}
private static @NonNull String getFormattedBodyForMms(@NonNull Context context, @NonNull MmsMessageRecord record) {
if (record.getSharedContacts().size() > 0) {
Contact contact = record.getSharedContacts().get(0);
return ContactUtil.getStringSummary(context, contact).toString();
} else if (record.getSlideDeck().getDocumentSlide() != null) {
return format(context, record, EmojiStrings.FILE, R.string.ThreadRecord_file);
} else if (record.getSlideDeck().getAudioSlide() != null) {
return format(context, record, EmojiStrings.AUDIO, R.string.ThreadRecord_voice_message);
} else if (MessageRecordUtil.hasSticker(record)) {
return format(context, record, EmojiStrings.STICKER, R.string.ThreadRecord_sticker);
}
boolean hasImage = false;
boolean hasVideo = false;
boolean hasGif = false;
for (Slide slide : record.getSlideDeck().getSlides()) {
hasVideo |= slide.hasVideo();
hasImage |= slide.hasImage();
hasGif |= slide instanceof GifSlide;
}
if (hasGif) {
return format(context, record, EmojiStrings.GIF, R.string.ThreadRecord_gif);
} else if (hasVideo) {
return format(context, record, EmojiStrings.VIDEO, R.string.ThreadRecord_video);
} else if (hasImage) {
return format(context, record, EmojiStrings.PHOTO, R.string.ThreadRecord_photo);
} else if (TextUtils.isEmpty(record.getBody())) {
Log.w(TAG, "Got a media message without a body of a type we were not able to process. [contains media slide]:" + record.containsMediaSlide());
return context.getString(R.string.ThreadRecord_media_message);
} else {
Log.w(TAG, "Got a media message with a body of a type we were not able to process. [contains media slide]:" + record.containsMediaSlide());
return record.getBody();
}
}
private static @NonNull String format(@NonNull Context context, @NonNull MessageRecord record, @NonNull String emoji, @StringRes int defaultStringRes) {
return String.format("%s %s", emoji, getBodyOrDefault(context, record, defaultStringRes));
}
private static @NonNull String getBodyOrDefault(@NonNull Context context, @NonNull MessageRecord record, @StringRes int defaultStringRes) {
if (TextUtils.isEmpty(record.getBody())) {
return context.getString(defaultStringRes);
} else {
return record.getBody();
}
}
}

View File

@@ -56,12 +56,14 @@ import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import java.io.Closeable;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
@@ -92,17 +94,27 @@ public class ThreadDatabase extends Database {
public static final String LAST_SEEN = "last_seen";
public static final String HAS_SENT = "has_sent";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" +
ID + " INTEGER PRIMARY KEY, " + DATE + " INTEGER DEFAULT 0, " +
MESSAGE_COUNT + " INTEGER DEFAULT 0, " + RECIPIENT_ID + " INTEGER, " + SNIPPET + " TEXT, " +
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " + READ + " INTEGER DEFAULT 1, " +
TYPE + " INTEGER DEFAULT 0, " + ERROR + " INTEGER DEFAULT 0, " +
SNIPPET_TYPE + " INTEGER DEFAULT 0, " + SNIPPET_URI + " TEXT DEFAULT NULL, " +
SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " + SNIPPET_EXTRAS + " TEXT DEFAULT NULL, " +
ARCHIVED + " INTEGER DEFAULT 0, " + STATUS + " INTEGER DEFAULT 0, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + EXPIRES_IN + " INTEGER DEFAULT 0, " +
LAST_SEEN + " INTEGER DEFAULT 0, " + HAS_SENT + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " + UNREAD_COUNT + " INTEGER DEFAULT 0);";
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ID + " INTEGER PRIMARY KEY, " +
DATE + " INTEGER DEFAULT 0, " +
MESSAGE_COUNT + " INTEGER DEFAULT 0, " +
RECIPIENT_ID + " INTEGER, " +
SNIPPET + " TEXT, " +
SNIPPET_CHARSET + " INTEGER DEFAULT 0, " +
READ + " INTEGER DEFAULT " + ReadStatus.READ.serialize() + ", " +
TYPE + " INTEGER DEFAULT 0, " +
ERROR + " INTEGER DEFAULT 0, " +
SNIPPET_TYPE + " INTEGER DEFAULT 0, " +
SNIPPET_URI + " TEXT DEFAULT NULL, " +
SNIPPET_CONTENT_TYPE + " TEXT DEFAULT NULL, " +
SNIPPET_EXTRAS + " TEXT DEFAULT NULL, " +
ARCHIVED + " INTEGER DEFAULT 0, " +
STATUS + " INTEGER DEFAULT 0, " +
DELIVERY_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
EXPIRES_IN + " INTEGER DEFAULT 0, " +
LAST_SEEN + " INTEGER DEFAULT 0, " +
HAS_SENT + " INTEGER DEFAULT 0, " +
READ_RECEIPT_COUNT + " INTEGER DEFAULT 0, " +
UNREAD_COUNT + " INTEGER DEFAULT 0);";
public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS thread_recipient_ids_index ON " + TABLE_NAME + " (" + RECIPIENT_ID + ");",
@@ -280,7 +292,7 @@ public class ThreadDatabase extends Database {
public List<MarkedMessageInfo> setAllThreadsRead() {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
ContentValues contentValues = new ContentValues(1);
contentValues.put(READ, 1);
contentValues.put(READ, ReadStatus.READ.serialize());
contentValues.put(UNREAD_COUNT, 0);
db.update(TABLE_NAME, contentValues, null, null);
@@ -310,32 +322,69 @@ public class ThreadDatabase extends Database {
}
public List<MarkedMessageInfo> setRead(long threadId, boolean lastSeen) {
ContentValues contentValues = new ContentValues(1);
contentValues.put(READ, 1);
contentValues.put(UNREAD_COUNT, 0);
if (lastSeen) {
contentValues.put(LAST_SEEN, System.currentTimeMillis());
}
return setRead(Collections.singletonList(threadId), lastSeen);
}
public List<MarkedMessageInfo> setRead(Collection<Long> threadIds, boolean lastSeen) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {threadId+""});
List<MarkedMessageInfo> smsRecords = new LinkedList<>();
List<MarkedMessageInfo> mmsRecords = new LinkedList<>();
final List<MarkedMessageInfo> smsRecords = DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId);
final List<MarkedMessageInfo> mmsRecords = DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId);
db.beginTransaction();
DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId);
DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId);
try {
ContentValues contentValues = new ContentValues(2);
contentValues.put(READ, ReadStatus.READ.serialize());
contentValues.put(UNREAD_COUNT, 0);
if (lastSeen) {
contentValues.put(LAST_SEEN, System.currentTimeMillis());
}
for (long threadId : threadIds) {
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[]{threadId + ""});
smsRecords.addAll(DatabaseFactory.getSmsDatabase(context).setMessagesRead(threadId));
mmsRecords.addAll(DatabaseFactory.getMmsDatabase(context).setMessagesRead(threadId));
DatabaseFactory.getSmsDatabase(context).setReactionsSeen(threadId);
DatabaseFactory.getMmsDatabase(context).setReactionsSeen(threadId);
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListListeners();
return Util.concatenatedList(smsRecords, mmsRecords);
}
public void setForcedUnread(@NonNull Collection<Long> threadIds) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.beginTransaction();
try {
ContentValues contentValues = new ContentValues();
contentValues.put(READ, ReadStatus.FORCED_UNREAD.serialize());
for (long threadId : threadIds) {
db.update(TABLE_NAME, contentValues, ID_WHERE, new String[] { String.valueOf(threadId) });
}
db.setTransactionSuccessful();
} finally {
db.endTransaction();
}
notifyConversationListListeners();
}
public void incrementUnread(long threadId, int amount) {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = 0, " +
db.execSQL("UPDATE " + TABLE_NAME + " SET " + READ + " = " + ReadStatus.UNREAD.serialize() + ", " +
UNREAD_COUNT + " = " + UNREAD_COUNT + " + ? WHERE " + ID + " = ?",
new String[] {String.valueOf(amount),
String.valueOf(threadId)});
@@ -708,7 +757,7 @@ public class ThreadDatabase extends Database {
MessageRecord record;
if (reader != null && (record = reader.getNext()) != null) {
updateThread(threadId, count, getFormattedBodyFor(record), getAttachmentUriFor(record),
updateThread(threadId, count, ThreadBodyUtil.getFormattedBodyFor(context, record), getAttachmentUriFor(record),
getContentTypeFor(record), getExtrasFor(record),
record.getTimestamp(), record.getDeliveryStatus(), record.getDeliveryReceiptCount(),
record.getType(), unarchive, record.getExpiresIn(), record.getReadReceiptCount());
@@ -725,15 +774,6 @@ public class ThreadDatabase extends Database {
}
}
private @NonNull String getFormattedBodyFor(@NonNull MessageRecord messageRecord) {
if (messageRecord.isMms() && ((MmsMessageRecord) messageRecord).getSharedContacts().size() > 0) {
Contact contact = ((MmsMessageRecord) messageRecord).getSharedContacts().get(0);
return ContactUtil.getStringSummary(context, contact).toString();
}
return messageRecord.getBody();
}
private @Nullable Uri getAttachmentUriFor(MessageRecord record) {
if (!record.isMms() || record.isMmsNotification() || record.isGroupAction()) return null;
@@ -848,31 +888,15 @@ public class ThreadDatabase extends Database {
}
public ThreadRecord getCurrent() {
long threadId = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID));
int distributionType = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE));
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)));
RecipientId recipientId = RecipientId.from(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.RECIPIENT_ID)));
Recipient recipient = Recipient.live(recipientId).get();
Recipient recipient = Recipient.live(recipientId).get();
String body = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET));
long date = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE));
long count = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT));
int unreadCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT));
long type = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE));
boolean archived = cursor.getInt(cursor.getColumnIndex(ThreadDatabase.ARCHIVED)) != 0;
int status = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS));
int deliveryReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT));
int readReceiptCount = cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT));
long expiresIn = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN));
long lastSeen = cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN));
Uri snippetUri = getSnippetUri(cursor);
String contentType = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE));
String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS));
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
}
int readReceiptCount = TextSecurePreferences.isReadReceiptsEnabled(context) ? cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ_RECEIPT_COUNT))
: 0;
Extra extra = null;
String extraString = cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_EXTRAS));
Extra extra = null;
if (extraString != null) {
try {
@@ -882,9 +906,25 @@ public class ThreadDatabase extends Database {
}
}
return new ThreadRecord(body, snippetUri, contentType, extra, recipient, date, count,
unreadCount, threadId, deliveryReceiptCount, status, type,
distributionType, archived, expiresIn, lastSeen, readReceiptCount);
return new ThreadRecord.Builder(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.ID)))
.setRecipient(recipient)
.setType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_TYPE)))
.setDistributionType(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.TYPE)))
.setBody(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET)))
.setDate(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.DATE)))
.setArchived(cursor.getInt(cursor.getColumnIndex(ThreadDatabase.ARCHIVED)) != 0)
.setDeliveryStatus(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.STATUS)))
.setDeliveryReceiptCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.DELIVERY_RECEIPT_COUNT)))
.setReadReceiptCount(readReceiptCount)
.setExpiresIn(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.EXPIRES_IN)))
.setLastSeen(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.LAST_SEEN)))
.setSnippetUri(getSnippetUri(cursor))
.setContentType(cursor.getString(cursor.getColumnIndexOrThrow(ThreadDatabase.SNIPPET_CONTENT_TYPE)))
.setCount(cursor.getLong(cursor.getColumnIndexOrThrow(ThreadDatabase.MESSAGE_COUNT)))
.setUnreadCount(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.UNREAD_COUNT)))
.setForcedUnread(cursor.getInt(cursor.getColumnIndexOrThrow(ThreadDatabase.READ)) == ReadStatus.FORCED_UNREAD.serialize())
.setExtra(extra)
.build();
}
private @Nullable Uri getSnippetUri(Cursor cursor) {
@@ -991,4 +1031,27 @@ public class ThreadDatabase extends Database {
return groupAddedBy;
}
}
private enum ReadStatus {
READ(1), UNREAD(0), FORCED_UNREAD(2);
private final int value;
ReadStatus(int value) {
this.value = value;
}
public static ReadStatus deserialize(int value) {
for (ReadStatus status : ReadStatus.values()) {
if (status.value == value) {
return status;
}
}
throw new IllegalArgumentException("No matching status for value " + value);
}
public int serialize() {
return value;
}
}
}

View File

@@ -35,10 +35,10 @@ import org.thoughtcrime.securesms.database.PushDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.migrations.LegacyMigrationJob;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.util.Base64;
@@ -434,7 +434,7 @@ public class ClassicOpenHelper extends SQLiteOpenHelper {
db.endTransaction();
// DecryptingQueue.schedulePendingDecrypts(context, masterSecret);
MessageNotifier.updateNotification(context);
ApplicationDependencies.getMessageNotifier().updateNotification(context);
}
@Override

View File

@@ -56,7 +56,7 @@ final class GroupsV2UpdateMessageProducer {
return context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(selfPending.get().getAddedByUuid()));
}
if (group.getVersion() == 0) {
if (group.getRevision() == 0) {
Optional<DecryptedMember> foundingMember = DecryptedGroupUtil.firstMember(group.getMembersList());
if (foundingMember.isPresent()) {
ByteString foundingMemberUuid = foundingMember.get().getUuid();
@@ -140,16 +140,16 @@ final class GroupsV2UpdateMessageProducer {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString member : change.getDeleteMembersList()) {
boolean newMemberIsYou = member.equals(selfUuidBytes);
boolean removedMemberIsYou = member.equals(selfUuidBytes);
if (editorIsYou) {
if (newMemberIsYou) {
if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_left_the_group));
} else {
updates.add(context.getString(R.string.MessageRecord_you_removed_s, describe(member)));
}
} else {
if (newMemberIsYou) {
if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_removed_you_from_the_group, describe(change.getEditor())));
} else {
if (member.equals(change.getEditor())) {

View File

@@ -157,7 +157,7 @@ public abstract class MessageRecord extends DisplayRecord {
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getVersion() > 0) {
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
DecryptedGroupChange change = decryptedGroupV2Context.getChange();
List<String> strings = updateMessageProducer.describeChange(change);
StringBuilder result = new StringBuilder();

View File

@@ -0,0 +1,29 @@
package org.thoughtcrime.securesms.database.model;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
final class StatusUtil {
private StatusUtil() {}
static boolean isDelivered(long deliveryStatus, int deliveryReceiptCount) {
return (deliveryStatus >= SmsDatabase.Status.STATUS_COMPLETE &&
deliveryStatus < SmsDatabase.Status.STATUS_PENDING) || deliveryReceiptCount > 0;
}
static boolean isPending(long type) {
return MmsSmsColumns.Types.isPendingMessageType(type) &&
!MmsSmsColumns.Types.isIdentityVerified(type) &&
!MmsSmsColumns.Types.isIdentityDefault(type);
}
static boolean isFailed(long type, long deliveryStatus) {
return MmsSmsColumns.Types.isFailedMessageType(type) ||
MmsSmsColumns.Types.isPendingSecureSmsFallbackType(type) ||
deliveryStatus >= SmsDatabase.Status.STATUS_FAILED;
}
static boolean isVerificationStatusChange(long type) {
return SmsDatabase.Types.isIdentityDefault(type) || SmsDatabase.Types.isIdentityVerified(type);
}
}

View File

@@ -17,151 +17,88 @@
*/
package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import android.net.Uri;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase.Extra;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.Objects;
/**
* The message record model which represents thread heading messages.
*
* @author Moxie Marlinspike
*
* Represents an entry in the {@link org.thoughtcrime.securesms.database.ThreadDatabase}.
*/
public class ThreadRecord extends DisplayRecord {
public final class ThreadRecord {
private @Nullable final Uri snippetUri;
private @Nullable final String contentType;
private @Nullable final Extra extra;
private final long count;
private final int unreadCount;
private final int distributionType;
private final boolean archived;
private final long expiresIn;
private final long lastSeen;
private final long threadId;
private final String body;
private final Recipient recipient;
private final long type;
private final long date;
private final long deliveryStatus;
private final int deliveryReceiptCount;
private final int readReceiptCount;
private final Uri snippetUri;
private final String contentType;
private final Extra extra;
private final long count;
private final int unreadCount;
private final boolean forcedUnread;
private final int distributionType;
private final boolean archived;
private final long expiresIn;
private final long lastSeen;
public ThreadRecord(@NonNull String body, @Nullable Uri snippetUri,
@Nullable String contentType, @Nullable Extra extra,
@NonNull Recipient recipient, long date, long count, int unreadCount,
long threadId, int deliveryReceiptCount, int status, long snippetType,
int distributionType, boolean archived, long expiresIn, long lastSeen,
int readReceiptCount)
{
super(body, recipient, date, date, threadId, status, deliveryReceiptCount, snippetType, readReceiptCount);
this.snippetUri = snippetUri;
this.contentType = contentType;
this.extra = extra;
this.count = count;
this.unreadCount = unreadCount;
this.distributionType = distributionType;
this.archived = archived;
this.expiresIn = expiresIn;
this.lastSeen = lastSeen;
private ThreadRecord(@NonNull Builder builder) {
this.threadId = builder.threadId;
this.body = builder.body;
this.recipient = builder.recipient;
this.date = builder.date;
this.type = builder.type;
this.deliveryStatus = builder.deliveryStatus;
this.deliveryReceiptCount = builder.deliveryReceiptCount;
this.readReceiptCount = builder.readReceiptCount;
this.snippetUri = builder.snippetUri;
this.contentType = builder.contentType;
this.extra = builder.extra;
this.count = builder.count;
this.unreadCount = builder.unreadCount;
this.forcedUnread = builder.forcedUnread;
this.distributionType = builder.distributionType;
this.archived = builder.archived;
this.expiresIn = builder.expiresIn;
this.lastSeen = builder.lastSeen;
}
public long getThreadId() {
return threadId;
}
public @NonNull Recipient getRecipient() {
return recipient;
}
public @Nullable Uri getSnippetUri() {
return snippetUri;
}
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (getGroupAddedBy() != null) {
return emphasisAdded(context.getString(isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
: R.string.ThreadRecord_s_added_you_to_the_group,
Recipient.live(getGroupAddedBy()).get().getDisplayName(context)));
} else if (!isMessageRequestAccepted()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
} else if (isGroupUpdate()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
} else if (isGroupQuit()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
} else if (isKeyExchange()) {
return emphasisAdded(context.getString(R.string.ConversationListItem_key_exchange_message));
} else if (SmsDatabase.Types.isFailedDecryptType(type)) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_bad_encrypted_message));
} else if (SmsDatabase.Types.isNoRemoteSessionType(type)) {
return emphasisAdded(context.getString(R.string.MessageDisplayHelper_message_encrypted_for_non_existing_session));
} else if (SmsDatabase.Types.isEndSessionType(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_secure_session_reset));
} else if (MmsSmsColumns.Types.isLegacyType(type)) {
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
} else if (MmsSmsColumns.Types.isDraftMessageType(type)) {
String draftText = context.getString(R.string.ThreadRecord_draft);
return emphasisAdded(draftText + " " + getBody(), 0, draftText.length());
} else if (SmsDatabase.Types.isOutgoingCall(type)) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
} else if (SmsDatabase.Types.isIncomingCall(type)) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
} else if (SmsDatabase.Types.isMissedCall(type)) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
} else if (SmsDatabase.Types.isJoinedType(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, getRecipient().toShortString(context)));
} else if (SmsDatabase.Types.isExpirationTimerUpdate(type)) {
int seconds = (int)(getExpiresIn() / 1000);
if (seconds <= 0) {
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_messages_disabled));
}
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return emphasisAdded(context.getString(R.string.ThreadRecord_disappearing_message_time_updated_to_s, time));
} else if (SmsDatabase.Types.isIdentityUpdate(type)) {
if (getRecipient().isGroup()) return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
else return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, getRecipient().toShortString(context)));
} else if (SmsDatabase.Types.isIdentityVerified(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
} else if (SmsDatabase.Types.isIdentityDefault(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_unverified));
} else if (SmsDatabase.Types.isUnsupportedMessageType(type)) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_could_not_be_processed));
} else {
if (TextUtils.isEmpty(getBody())) {
if (extra != null && extra.isSticker()) {
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_sticker)));
} else if (extra != null && extra.isViewOnce()) {
return new SpannableString(emphasisAdded(getViewOnceDescription(context, contentType)));
} else if (extra != null && extra.isRemoteDelete()) {
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_this_message_was_deleted)));
} else {
return new SpannableString(emphasisAdded(context.getString(R.string.ThreadRecord_media_message)));
}
} else {
return new SpannableString(getBody());
}
}
public @NonNull String getBody() {
return body;
}
private SpannableString emphasisAdded(String sequence) {
return emphasisAdded(sequence, 0, sequence.length());
public @Nullable Extra getExtra() {
return extra;
}
private SpannableString emphasisAdded(String sequence, int start, int end) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
private String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
if (MediaUtil.isViewOnceType(contentType)) {
return context.getString(R.string.ThreadRecord_view_once_media);
} else if (MediaUtil.isVideoType(contentType)) {
return context.getString(R.string.ThreadRecord_view_once_video);
} else {
return context.getString(R.string.ThreadRecord_view_once_photo);
}
public @Nullable String getContentType() {
return contentType;
}
public long getCount() {
@@ -172,14 +109,26 @@ public class ThreadRecord extends DisplayRecord {
return unreadCount;
}
public boolean isForcedUnread() {
return forcedUnread;
}
public boolean isRead() {
return unreadCount == 0 && !forcedUnread;
}
public long getDate() {
return getDateReceived();
return date;
}
public boolean isArchived() {
return archived;
}
public long getType() {
return type;
}
public int getDistributionType() {
return distributionType;
}
@@ -192,6 +141,38 @@ public class ThreadRecord extends DisplayRecord {
return lastSeen;
}
public boolean isOutgoing() {
return MmsSmsColumns.Types.isOutgoingMessageType(type);
}
public boolean isOutgoingCall() {
return SmsDatabase.Types.isOutgoingCall(type);
}
public boolean isVerificationStatusChange() {
return StatusUtil.isVerificationStatusChange(type);
}
public boolean isPending() {
return StatusUtil.isPending(type);
}
public boolean isFailed() {
return StatusUtil.isFailed(type, deliveryStatus);
}
public boolean isRemoteRead() {
return readReceiptCount > 0;
}
public boolean isPendingInsecureSmsFallback() {
return SmsDatabase.Types.isPendingInsecureSmsFallbackType(type);
}
public boolean isDelivered() {
return StatusUtil.isDelivered(deliveryStatus, deliveryReceiptCount);
}
public @Nullable RecipientId getGroupAddedBy() {
if (extra != null && extra.getGroupAddedBy() != null) return RecipientId.from(extra.getGroupAddedBy());
else return null;
@@ -205,4 +186,129 @@ public class ThreadRecord extends DisplayRecord {
if (extra != null) return extra.isMessageRequestAccepted();
else return true;
}
public static class Builder {
private long threadId;
private String body;
private Recipient recipient;
private long type;
private long date;
private long deliveryStatus;
private int deliveryReceiptCount;
private int readReceiptCount;
private Uri snippetUri;
private String contentType;
private Extra extra;
private long count;
private int unreadCount;
private boolean forcedUnread;
private int distributionType;
private boolean archived;
private long expiresIn;
private long lastSeen;
public Builder(long threadId) {
this.threadId = threadId;
}
public Builder setBody(@NonNull String body) {
this.body = body;
return this;
}
public Builder setRecipient(@NonNull Recipient recipient) {
this.recipient = recipient;
return this;
}
public Builder setType(long type) {
this.type = type;
return this;
}
public Builder setThreadId(long threadId) {
this.threadId = threadId;
return this;
}
public Builder setDate(long date) {
this.date = date;
return this;
}
public Builder setDeliveryStatus(long deliveryStatus) {
this.deliveryStatus = deliveryStatus;
return this;
}
public Builder setDeliveryReceiptCount(int deliveryReceiptCount) {
this.deliveryReceiptCount = deliveryReceiptCount;
return this;
}
public Builder setReadReceiptCount(int readReceiptCount) {
this.readReceiptCount = readReceiptCount;
return this;
}
public Builder setSnippetUri(@Nullable Uri snippetUri) {
this.snippetUri = snippetUri;
return this;
}
public Builder setContentType(@Nullable String contentType) {
this.contentType = contentType;
return this;
}
public Builder setExtra(@Nullable Extra extra) {
this.extra = extra;
return this;
}
public Builder setCount(long count) {
this.count = count;
return this;
}
public Builder setUnreadCount(int unreadCount) {
this.unreadCount = unreadCount;
return this;
}
public Builder setForcedUnread(boolean forcedUnread) {
this.forcedUnread = forcedUnread;
return this;
}
public Builder setDistributionType(int distributionType) {
this.distributionType = distributionType;
return this;
}
public Builder setArchived(boolean archived) {
this.archived = archived;
return this;
}
public Builder setExpiresIn(long expiresIn) {
this.expiresIn = expiresIn;
return this;
}
public Builder setLastSeen(long lastSeen) {
this.lastSeen = lastSeen;
return this;
}
public ThreadRecord build() {
if (distributionType == ThreadDatabase.DistributionTypes.CONVERSATION) {
Preconditions.checkArgument(threadId > 0);
Preconditions.checkArgument(date > 0);
Preconditions.checkNotNull(body);
Preconditions.checkNotNull(recipient);
}
return new ThreadRecord(this);
}
}
}

View File

@@ -2,20 +2,24 @@ package org.thoughtcrime.securesms.dependencies;
import android.app.Application;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.IncomingMessageProcessor;
import org.thoughtcrime.securesms.gcm.MessageRetriever;
import org.thoughtcrime.securesms.messages.IncomingMessageProcessor;
import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever;
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache;
import org.thoughtcrime.securesms.groups.v2.processing.GroupsV2StateProcessor;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.messages.InitialMessageRetriever;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FrameRateTracker;
@@ -45,7 +49,7 @@ public class ApplicationDependencies {
private static SignalServiceMessageSender messageSender;
private static SignalServiceMessageReceiver messageReceiver;
private static IncomingMessageProcessor incomingMessageProcessor;
private static MessageRetriever messageRetriever;
private static BackgroundMessageRetriever backgroundMessageRetriever;
private static LiveRecipientCache recipientCache;
private static JobManager jobManager;
private static FrameRateTracker frameRateTracker;
@@ -55,14 +59,18 @@ public class ApplicationDependencies {
private static GroupsV2StateProcessor groupsV2StateProcessor;
private static GroupsV2Operations groupsV2Operations;
private static EarlyMessageCache earlyMessageCache;
private static InitialMessageRetriever initialMessageRetriever;
private static MessageNotifier messageNotifier;
@MainThread
public static synchronized void init(@NonNull Application application, @NonNull Provider provider) {
if (ApplicationDependencies.application != null || ApplicationDependencies.provider != null) {
throw new IllegalStateException("Already initialized!");
}
ApplicationDependencies.application = application;
ApplicationDependencies.provider = provider;
ApplicationDependencies.application = application;
ApplicationDependencies.provider = provider;
ApplicationDependencies.messageNotifier = provider.provideMessageNotifier();
}
public static @NonNull Application getApplication() {
@@ -164,14 +172,14 @@ public class ApplicationDependencies {
return incomingMessageProcessor;
}
public static synchronized @NonNull MessageRetriever getMessageRetriever() {
public static synchronized @NonNull BackgroundMessageRetriever getBackgroundMessageRetriever() {
assertInitialization();
if (messageRetriever == null) {
messageRetriever = provider.provideMessageRetriever();
if (backgroundMessageRetriever == null) {
backgroundMessageRetriever = provider.provideBackgroundMessageRetriever();
}
return messageRetriever;
return backgroundMessageRetriever;
}
public static synchronized @NonNull LiveRecipientCache getRecipientCache() {
@@ -234,6 +242,21 @@ public class ApplicationDependencies {
return earlyMessageCache;
}
public static synchronized @NonNull InitialMessageRetriever getInitialMessageRetriever() {
assertInitialization();
if (initialMessageRetriever == null) {
initialMessageRetriever = provider.provideInitialMessageRetriever();
}
return initialMessageRetriever;
}
public static synchronized @NonNull MessageNotifier getMessageNotifier() {
assertInitialization();
return messageNotifier;
}
private static void assertInitialization() {
if (application == null || provider == null) {
throw new UninitializedException();
@@ -247,13 +270,15 @@ public class ApplicationDependencies {
@NonNull SignalServiceMessageReceiver provideSignalServiceMessageReceiver();
@NonNull SignalServiceNetworkAccess provideSignalServiceNetworkAccess();
@NonNull IncomingMessageProcessor provideIncomingMessageProcessor();
@NonNull MessageRetriever provideMessageRetriever();
@NonNull BackgroundMessageRetriever provideBackgroundMessageRetriever();
@NonNull LiveRecipientCache provideRecipientCache();
@NonNull JobManager provideJobManager();
@NonNull FrameRateTracker provideFrameRateTracker();
@NonNull KeyValueStore provideKeyValueStore();
@NonNull MegaphoneRepository provideMegaphoneRepository();
@NonNull EarlyMessageCache provideEarlyMessageCache();
@NonNull InitialMessageRetriever provideInitialMessageRetriever();
@NonNull MessageNotifier provideMessageNotifier();
}
private static class UninitializedException extends IllegalStateException {

View File

@@ -7,11 +7,11 @@ import androidx.annotation.NonNull;
import org.greenrobot.eventbus.EventBus;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.IncomingMessageProcessor;
import org.thoughtcrime.securesms.messages.IncomingMessageProcessor;
import org.thoughtcrime.securesms.crypto.storage.SignalProtocolStoreImpl;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.gcm.MessageRetriever;
import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JobMigrator;
import org.thoughtcrime.securesms.jobmanager.impl.JsonDataSerializer;
@@ -20,15 +20,20 @@ import org.thoughtcrime.securesms.jobs.JobManagerFactories;
import org.thoughtcrime.securesms.keyvalue.KeyValueStore;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.messages.InitialMessageRetriever;
import org.thoughtcrime.securesms.notifications.DefaultMessageNotifier;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.notifications.OptimizedMessageNotifier;
import org.thoughtcrime.securesms.push.SecurityEventListener;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.LiveRecipientCache;
import org.thoughtcrime.securesms.service.IncomingMessageObserver;
import org.thoughtcrime.securesms.messages.IncomingMessageObserver;
import org.thoughtcrime.securesms.util.AlarmSleepTimer;
import org.thoughtcrime.securesms.util.EarlyMessageCache;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FrameRateTracker;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
@@ -85,7 +90,8 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
Optional.fromNullable(IncomingMessageObserver.getPipe()),
Optional.fromNullable(IncomingMessageObserver.getUnidentifiedPipe()),
Optional.of(new SecurityEventListener(context)),
provideClientZkOperations().getProfileOperations());
provideClientZkOperations().getProfileOperations(),
SignalExecutors.UNBOUNDED);
}
@Override
@@ -111,8 +117,8 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
}
@Override
public @NonNull MessageRetriever provideMessageRetriever() {
return new MessageRetriever();
public @NonNull BackgroundMessageRetriever provideBackgroundMessageRetriever() {
return new BackgroundMessageRetriever();
}
@Override
@@ -152,6 +158,16 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new EarlyMessageCache();
}
@Override
public @NonNull InitialMessageRetriever provideInitialMessageRetriever() {
return new InitialMessageRetriever();
}
@Override
public @NonNull MessageNotifier provideMessageNotifier() {
return new OptimizedMessageNotifier(new DefaultMessageNotifier());
}
private static class DynamicCredentialsProvider implements CredentialsProvider {
private final Context context;

View File

@@ -0,0 +1,100 @@
package org.thoughtcrime.securesms.gcm;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.firebase.messaging.RemoteMessage;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever;
import org.thoughtcrime.securesms.messages.RestStrategy;
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.concurrent.atomic.AtomicInteger;
/**
* This service does the actual network fetch in response to an FCM message.
*
* Our goals with FCM processing are as follows:
* (1) Ensure some service is active for the duration of the fetch and processing stages.
* (2) Do not make unnecessary network requests.
*
* To fulfill goal 1, this service will not call {@link #stopSelf()} until there is no more running
* requests.
*
* To fulfill goal 2, this service will not enqueue a fetch if there are already 2 active fetches
* (or rather, 1 active and 1 waiting, since we use a single thread executor).
*
* Unfortunately we can't do this all in {@link FcmReceiveService} because it won't let us process
* the next FCM message until {@link FcmReceiveService#onMessageReceived(RemoteMessage)} returns,
* but as soon as that method returns, it could also destroy the service. By not letting us control
* when the service is destroyed, we can't accomplish both goals within that service.
*/
public class FcmFetchService extends Service {
private static final String TAG = Log.tag(FcmFetchService.class);
private static final SerialMonoLifoExecutor EXECUTOR = new SerialMonoLifoExecutor(SignalExecutors.UNBOUNDED);
private final AtomicInteger activeCount = new AtomicInteger(0);
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
boolean performedReplace = EXECUTOR.enqueue(this::fetch);
if (performedReplace) {
Log.i(TAG, "Already have one running and one enqueued. Ignoring.");
} else {
int count = activeCount.incrementAndGet();
Log.i(TAG, "Incrementing active count to " + count);
}
return START_NOT_STICKY;
}
@Override
public void onDestroy() {
Log.i(TAG, "onDestroy()");
}
@Override
public @Nullable IBinder onBind(Intent intent) {
return null;
}
private void fetch() {
retrieveMessages(this);
if (activeCount.decrementAndGet() == 0) {
Log.e(TAG, "stopping");
stopSelf();
}
}
static void retrieveMessages(@NonNull Context context) {
BackgroundMessageRetriever retriever = ApplicationDependencies.getBackgroundMessageRetriever();
boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy());
if (success) {
Log.i(TAG, "Successfully retrieved messages.");
} else {
if (Build.VERSION.SDK_INT >= 26) {
Log.w(TAG, "Failed to retrieve messages. Scheduling on the system JobScheduler (API " + Build.VERSION.SDK_INT + ").");
FcmJobService.schedule(context);
} else {
Log.w(TAG, "Failed to retrieve messages. Scheduling on JobManager (API " + Build.VERSION.SDK_INT + ").");
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob(context));
}
}
}
}

View File

@@ -9,16 +9,16 @@ import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.messages.BackgroundMessageRetriever;
import org.thoughtcrime.securesms.messages.RestStrategy;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
/**
* Pulls down messages. Used when we fail to pull down messages in {@link FcmService}.
* Pulls down messages. Used when we fail to pull down messages in {@link FcmReceiveService}.
*/
@RequiresApi(26)
public class FcmJobService extends JobService {
@@ -41,15 +41,15 @@ public class FcmJobService extends JobService {
public boolean onStartJob(JobParameters params) {
Log.d(TAG, "onStartJob()");
if (MessageRetriever.shouldIgnoreFetch(this)) {
if (BackgroundMessageRetriever.shouldIgnoreFetch(this)) {
Log.i(TAG, "App is foregrounded. No need to run.");
return false;
}
SignalExecutors.UNBOUNDED.execute(() -> {
Context context = getApplicationContext();
MessageRetriever retriever = ApplicationDependencies.getMessageRetriever();
boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy());
Context context = getApplicationContext();
BackgroundMessageRetriever retriever = ApplicationDependencies.getBackgroundMessageRetriever();
boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy());
if (success) {
Log.i(TAG, "Successfully retrieved messages.");

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.gcm;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.annotation.NonNull;
@@ -8,18 +9,17 @@ import androidx.annotation.NonNull;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.messages.RestStrategy;
import org.thoughtcrime.securesms.registration.PushChallengeRequest;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
public class FcmService extends FirebaseMessagingService {
public class FcmReceiveService extends FirebaseMessagingService {
private static final String TAG = FcmService.class.getSimpleName();
private static final String TAG = FcmReceiveService.class.getSimpleName();
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
@@ -29,7 +29,7 @@ public class FcmService extends FirebaseMessagingService {
if (challenge != null) {
handlePushChallenge(challenge);
} else {
handleReceivedNotification(getApplicationContext());
handleReceivedNotification(ApplicationDependencies.getApplication());
}
}
@@ -37,7 +37,7 @@ public class FcmService extends FirebaseMessagingService {
public void onNewToken(String token) {
Log.i(TAG, "onNewToken()");
if (!TextSecurePreferences.isPushRegistered(getApplicationContext())) {
if (!TextSecurePreferences.isPushRegistered(ApplicationDependencies.getApplication())) {
Log.i(TAG, "Got a new FCM token, but the user isn't registered.");
return;
}
@@ -46,22 +46,12 @@ public class FcmService extends FirebaseMessagingService {
}
private static void handleReceivedNotification(Context context) {
MessageRetriever retriever = ApplicationDependencies.getMessageRetriever();
boolean success = retriever.retrieveMessages(context, new RestStrategy(), new RestStrategy());
if (success) {
Log.i(TAG, "Successfully retrieved messages.");
} else {
if (Build.VERSION.SDK_INT >= 26) {
Log.w(TAG, "Failed to retrieve messages. Scheduling on the system JobScheduler (API " + Build.VERSION.SDK_INT + ").");
FcmJobService.schedule(context);
} else {
Log.w(TAG, "Failed to retrieve messages. Scheduling on JobManager (API " + Build.VERSION.SDK_INT + ").");
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob(context));
}
try {
context.startService(new Intent(context, FcmFetchService.class));
} catch (Exception e) {
Log.w(TAG, "Failed to start service. Falling back to legacy approach.");
FcmFetchService.retrieveMessages(context);
}
Log.i(TAG, "Processing complete.");
}
private static void handlePushChallenge(@NonNull String challenge) {
@@ -69,4 +59,4 @@ public class FcmService extends FirebaseMessagingService {
PushChallengeRequest.postChallengeResponse(challenge);
}
}
}

View File

@@ -4,7 +4,7 @@ import androidx.annotation.NonNull;
public final class BadGroupIdException extends Exception {
public BadGroupIdException() {
BadGroupIdException() {
super();
}

View File

@@ -147,12 +147,13 @@ public final class GroupManager {
@WorkerThread
public static void updateGroupFromServer(@NonNull Context context,
@NonNull GroupMasterKey groupMasterKey,
int version,
long timestamp)
int revision,
long timestamp,
@Nullable byte[] signedGroupChange)
throws GroupChangeBusyException, IOException, GroupNotAMemberException
{
try (GroupManagerV2.GroupUpdater updater = new GroupManagerV2(context).updater(groupMasterKey)) {
updater.updateLocalToServerVersion(version, timestamp);
updater.updateLocalToServerRevision(revision, timestamp, signedGroupChange);
}
}
@@ -246,7 +247,7 @@ public final class GroupManager {
} else {
GroupDatabase.GroupRecord groupRecord = DatabaseFactory.getGroupDatabase(context).requireGroup(groupId);
List<RecipientId> members = groupRecord.getMembers();
byte[] avatar = Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId()));
byte[] avatar = groupRecord.hasAvatar() ? Util.readFully(AvatarHelper.getAvatar(context, groupRecord.getRecipientId())) : null;
Set<RecipientId> addresses = new HashSet<>(members);
addresses.addAll(newMembers);

View File

@@ -138,7 +138,7 @@ final class GroupManagerV1 {
if (avatar != null) {
Uri avatarUri = BlobProvider.getInstance().forData(avatar).createForSingleUseInMemory();
avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null, null, null, null);
avatarAttachment = new UriAttachment(avatarUri, MediaUtil.IMAGE_PNG, AttachmentDatabase.TRANSFER_PROGRESS_DONE, avatar.length, null, false, false, null, null, null, null, null);
}
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient, groupContext, avatarAttachment, System.currentTimeMillis(), 0, false, null, Collections.emptyList(), Collections.emptyList());

View File

@@ -6,6 +6,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.protobuf.InvalidProtocolBufferException;
import org.signal.storageservice.protos.groups.AccessControl;
import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.Member;
@@ -144,7 +146,7 @@ final class GroupManagerV2 {
groupDatabase.onAvatarUpdated(groupId, avatar != null);
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(groupRecipient.getId(), true);
return sendGroupUpdate(masterKey, decryptedGroup, null);
return sendGroupUpdate(masterKey, decryptedGroup, null, null);
} catch (VerificationFailedException | InvalidGroupStateException e) {
throw new GroupChangeFailedException(e);
}
@@ -262,14 +264,9 @@ final class GroupManagerV2 {
@NonNull GroupManager.GroupActionResult ejectMember(@NonNull RecipientId recipientId)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException
{
Recipient recipient = Recipient.resolved(recipientId);
GroupManager.GroupActionResult result = commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.getUuid().get())));
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isLocalNumber()) {
groupDatabase.setActive(groupId, false);
}
return result;
return commitChangeWithConflictResolution(groupOperations.createRemoveMembersChange(Collections.singleton(recipient.getUuid().get())));
}
@WorkerThread
@@ -354,7 +351,7 @@ final class GroupManagerV2 {
throws IOException, GroupNotAMemberException, GroupChangeFailedException
{
GroupsV2StateProcessor.GroupUpdateResult groupUpdateResult = groupsV2StateProcessor.forGroup(groupMasterKey)
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis());
.updateLocalGroupToRevision(GroupsV2StateProcessor.LATEST, System.currentTimeMillis(), null);
if (groupUpdateResult.getGroupState() != GroupsV2StateProcessor.GroupState.GROUP_UPDATED || groupUpdateResult.getLatestServer() == null) {
throw new GroupChangeFailedException();
@@ -378,7 +375,7 @@ final class GroupManagerV2 {
final GroupDatabase.GroupRecord groupRecord = groupDatabase.requireGroup(groupId);
final GroupDatabase.V2GroupProperties v2GroupProperties = groupRecord.requireV2GroupProperties();
final int nextRevision = v2GroupProperties.getGroupRevision() + 1;
final GroupChange.Actions changeActions = change.setVersion(nextRevision).build();
final GroupChange.Actions changeActions = change.setRevision(nextRevision).build();
final DecryptedGroupChange decryptedChange;
final DecryptedGroup decryptedGroupState;
@@ -390,24 +387,24 @@ final class GroupManagerV2 {
throw new IOException(e);
}
commitToServer(changeActions);
GroupChange signedGroupChange = commitToServer(changeActions);
groupDatabase.update(groupId, decryptedGroupState);
return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange);
return sendGroupUpdate(groupMasterKey, decryptedGroupState, decryptedChange, signedGroupChange);
}
private void commitToServer(GroupChange.Actions change)
private GroupChange commitToServer(GroupChange.Actions change)
throws GroupNotAMemberException, GroupChangeFailedException, IOException, GroupInsufficientRightsException
{
try {
groupsV2Api.patchGroup(change, groupSecretParams, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
return groupsV2Api.patchGroup(change, authorization.getAuthorizationForToday(selfUuid, groupSecretParams));
} catch (NotInGroupException e) {
Log.w(TAG, e);
throw new GroupNotAMemberException(e);
} catch (AuthorizationFailedException e) {
Log.w(TAG, e);
throw new GroupInsufficientRightsException(e);
} catch (VerificationFailedException | InvalidGroupStateException e) {
} catch (VerificationFailedException e) {
Log.w(TAG, e);
throw new GroupChangeFailedException(e);
}
@@ -430,11 +427,25 @@ final class GroupManagerV2 {
}
@WorkerThread
void updateLocalToServerVersion(int version, long timestamp)
void updateLocalToServerRevision(int revision, long timestamp, @Nullable byte[] signedGroupChange)
throws IOException, GroupNotAMemberException
{
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
.updateLocalGroupToRevision(version, timestamp);
new GroupsV2StateProcessor(context).forGroup(groupMasterKey)
.updateLocalGroupToRevision(revision, timestamp, getDecryptedGroupChange(signedGroupChange));
}
private DecryptedGroupChange getDecryptedGroupChange(@Nullable byte[] signedGroupChange) {
if (signedGroupChange != null) {
GroupsV2Operations.GroupOperations groupOperations = groupsV2Operations.forGroup(GroupSecretParams.deriveFromMasterKey(groupMasterKey));
try {
return groupOperations.decryptChange(GroupChange.parseFrom(signedGroupChange), true);
} catch (VerificationFailedException | InvalidGroupStateException | InvalidProtocolBufferException e) {
Log.w(TAG, "Unable to verify supplied group change", e);
}
}
return null;
}
@Override
@@ -445,11 +456,12 @@ final class GroupManagerV2 {
private @NonNull GroupManager.GroupActionResult sendGroupUpdate(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange)
@Nullable DecryptedGroupChange plainGroupChange,
@Nullable GroupChange signedGroupChange)
{
GroupId.V2 groupId = GroupId.v2(masterKey);
Recipient groupRecipient = Recipient.externalGroup(context, groupId);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange);
DecryptedGroupV2Context decryptedGroupV2Context = GroupProtoUtil.createDecryptedGroupV2Context(masterKey, decryptedGroup, plainGroupChange, signedGroupChange);
OutgoingGroupUpdateMessage outgoingMessage = new OutgoingGroupUpdateMessage(groupRecipient,
decryptedGroupV2Context,
null,

View File

@@ -8,17 +8,19 @@ import androidx.annotation.WorkerThread;
import com.google.protobuf.ByteString;
import org.signal.storageservice.protos.groups.GroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
import org.signal.zkgroup.groups.GroupMasterKey;
import org.signal.zkgroup.util.UUIDUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
import java.util.List;
import java.util.UUID;
@@ -28,19 +30,19 @@ public final class GroupProtoUtil {
private GroupProtoUtil() {
}
public static int findVersionWeWereAdded(@NonNull DecryptedGroup group, @NonNull UUID uuid)
public static int findRevisionWeWereAdded(@NonNull DecryptedGroup group, @NonNull UUID uuid)
throws GroupNotAMemberException
{
ByteString bytes = UuidUtil.toByteString(uuid);
for (DecryptedMember decryptedMember : group.getMembersList()) {
if (decryptedMember.getUuid().equals(bytes)) {
return decryptedMember.getJoinedAtVersion();
return decryptedMember.getJoinedAtRevision();
}
}
for (DecryptedPendingMember decryptedMember : group.getPendingMembersList()) {
if (decryptedMember.getUuid().equals(bytes)) {
// Assume latest, we don't have any information about when pending members were invited
return group.getVersion();
return group.getRevision();
}
}
throw new GroupNotAMemberException();
@@ -48,16 +50,20 @@ public final class GroupProtoUtil {
public static DecryptedGroupV2Context createDecryptedGroupV2Context(@NonNull GroupMasterKey masterKey,
@NonNull DecryptedGroup decryptedGroup,
@Nullable DecryptedGroupChange plainGroupChange)
@Nullable DecryptedGroupChange plainGroupChange,
@Nullable GroupChange signedServerChange)
{
int version = plainGroupChange != null ? plainGroupChange.getVersion() : decryptedGroup.getVersion();
SignalServiceProtos.GroupContextV2 groupContext = SignalServiceProtos.GroupContextV2.newBuilder()
.setMasterKey(ByteString.copyFrom(masterKey.serialize()))
.setRevision(version)
.build();
int revision = plainGroupChange != null ? plainGroupChange.getRevision() : decryptedGroup.getRevision();
SignalServiceProtos.GroupContextV2.Builder contextBuilder = SignalServiceProtos.GroupContextV2.newBuilder()
.setMasterKey(ByteString.copyFrom(masterKey.serialize()))
.setRevision(revision);
if (signedServerChange != null) {
contextBuilder.setGroupChange(signedServerChange.toByteString());
}
DecryptedGroupV2Context.Builder builder = DecryptedGroupV2Context.newBuilder()
.setContext(groupContext)
.setContext(contextBuilder.build())
.setGroupState(decryptedGroup);
if (plainGroupChange != null) {
@@ -83,6 +89,18 @@ public final class GroupProtoUtil {
return Recipient.externalPush(context, uuid, null);
}
@WorkerThread
public static @NonNull RecipientId uuidByteStringToRecipientId(@NonNull ByteString uuidByteString) {
UUID uuid = UUIDUtil.deserialize(uuidByteString.toByteArray());
if (uuid.equals(GroupsV2Operations.UNKNOWN_UUID)) {
return RecipientId.UNKNOWN;
}
return RecipientId.from(uuid, null);
}
public static boolean isMember(@NonNull UUID uuid, @NonNull List<DecryptedMember> membersList) {
ByteString uuidBytes = UuidUtil.toByteString(uuid);

View File

@@ -20,7 +20,6 @@ import org.thoughtcrime.securesms.jobs.PushGroupUpdateJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingGroupUpdateMessage;
import org.thoughtcrime.securesms.notifications.MessageNotifier;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -252,7 +251,7 @@ public final class GroupV1MessageProcessor {
Optional<InsertResult> insertResult = smsDatabase.insertMessageInbox(groupMessage);
if (insertResult.isPresent()) {
MessageNotifier.updateNotification(context, insertResult.get().getThreadId());
ApplicationDependencies.getMessageNotifier().updateNotification(context, insertResult.get().getThreadId());
return insertResult.get().getThreadId();
} else {
return null;

View File

@@ -42,25 +42,46 @@ final class GroupsV2CapabilityChecker {
Recipient member = Recipient.resolved(recipientId);
Recipient.Capability gv2Capability = member.getGroupsV2Capability();
if (gv2Capability == Recipient.Capability.UNKNOWN) {
if (gv2Capability != Recipient.Capability.SUPPORTED) {
if (!ApplicationDependencies.getJobManager().runSynchronously(RetrieveProfileJob.forRecipient(member), TimeUnit.SECONDS.toMillis(1000)).isPresent()) {
throw new IOException("Recipient capability was not retrieved in time");
}
}
}
boolean noSelfGV2Support = false;
int noGv2Count = 0;
int noUuidCount = 0;
for (RecipientId recipientId : recipientIdsSet) {
Recipient member = Recipient.resolved(recipientId);
Recipient.Capability gv2Capability = member.getGroupsV2Capability();
if (gv2Capability != Recipient.Capability.SUPPORTED) {
Log.i(TAG, "At least one recipient does not support GV2, capability was " + gv2Capability);
return false;
Log.w(TAG, "At least one recipient does not support GV2, capability was " + gv2Capability);
noGv2Count++;
if (member.isLocalNumber()) {
noSelfGV2Support = true;
}
}
if (!member.hasUuid()) {
noUuidCount++;
}
}
for (RecipientId recipientId : recipientIdsSet) {
Recipient member = Recipient.resolved(recipientId);
if (!member.hasUuid()) {
Log.i(TAG, "At least one recipient did not have a UUID known to us");
return false;
if (noGv2Count + noUuidCount > 0) {
if (noUuidCount > 0) {
Log.w(TAG, noUuidCount + " recipient(s) did not have a UUID known to us");
}
if (noGv2Count > 0) {
Log.w(TAG, noGv2Count + " recipient(s) do not support GV2");
if (noSelfGV2Support) {
Log.w(TAG, "Self does not support GV2");
}
}
return false;
}
return true;

View File

@@ -29,8 +29,10 @@ public final class LiveGroup {
private static final Comparator<GroupMemberEntry.FullMember> LOCAL_FIRST = (m1, m2) -> Boolean.compare(m2.getMember().isLocalNumber(), m1.getMember().isLocalNumber());
private static final Comparator<GroupMemberEntry.FullMember> ADMIN_FIRST = (m1, m2) -> Boolean.compare(m2.isAdmin(), m1.isAdmin());
private static final Comparator<GroupMemberEntry.FullMember> ALPHABETICAL = (m1, m2) -> m1.getMember().toShortString(ApplicationDependencies.getApplication()).compareToIgnoreCase(m2.getMember().toShortString(ApplicationDependencies.getApplication()));
private static final Comparator<? super GroupMemberEntry.FullMember> MEMBER_ORDER = ComparatorCompat.chain(LOCAL_FIRST)
.thenComparing(ADMIN_FIRST);
.thenComparing(ADMIN_FIRST)
.thenComparing(ALPHABETICAL);
private final GroupDatabase groupDatabase;
private final LiveData<Recipient> recipient;
@@ -91,11 +93,11 @@ public final class LiveGroup {
}
public LiveData<Boolean> selfCanEditGroupAttributes() {
return LiveDataUtil.combineLatest(isSelfAdmin(), getAttributesAccessControl(), this::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getAttributesAccessControl(), LiveGroup::applyAccessControl);
}
public LiveData<Boolean> selfCanAddMembers() {
return LiveDataUtil.combineLatest(isSelfAdmin(), getMembershipAdditionAccessControl(), this::applyAccessControl);
return LiveDataUtil.combineLatest(selfMemberLevel(), getMembershipAdditionAccessControl(), LiveGroup::applyAccessControl);
}
/**
@@ -121,11 +123,28 @@ public final class LiveGroup {
fullMemberCount);
}
private boolean applyAccessControl(boolean isAdmin, @NonNull GroupAccessControl rights) {
private LiveData<MemberLevel> selfMemberLevel() {
return Transformations.map(groupRecord, g -> {
if (g.isAdmin(Recipient.self())) {
return MemberLevel.ADMIN;
} else {
return g.isActive() ? MemberLevel.MEMBER
: MemberLevel.NOT_A_MEMBER;
}
});
}
private static boolean applyAccessControl(@NonNull MemberLevel memberLevel, @NonNull GroupAccessControl rights) {
switch (rights) {
case ALL_MEMBERS: return true;
case ONLY_ADMINS: return isAdmin;
case ALL_MEMBERS: return memberLevel != MemberLevel.NOT_A_MEMBER;
case ONLY_ADMINS: return memberLevel == MemberLevel.ADMIN;
default: throw new AssertionError();
}
}
private enum MemberLevel {
NOT_A_MEMBER,
MEMBER,
ADMIN
}
}

View File

@@ -0,0 +1,5 @@
package org.thoughtcrime.securesms.groups.ui;
public interface AddMembersResultCallback {
void onMembersAdded(int numberOfMembersAdded);
}

View File

@@ -35,8 +35,7 @@ public abstract class GroupMemberEntry {
public final static class NewGroupCandidate extends GroupMemberEntry {
private final DefaultValueLiveData<Boolean> isSelected = new DefaultValueLiveData<>(false);
private final Recipient member;
private final Recipient member;
public NewGroupCandidate(@NonNull Recipient member) {
this.member = member;
@@ -46,14 +45,6 @@ public abstract class GroupMemberEntry {
return member;
}
public @NonNull LiveData<Boolean> isSelected() {
return isSelected;
}
public void setSelected(boolean isSelected) {
this.isSelected.postValue(isSelected);
}
@Override
boolean sameId(@NonNull GroupMemberEntry newItem) {
if (getClass() != newItem.getClass()) return false;

View File

@@ -246,9 +246,6 @@ final class GroupMemberListAdapter extends LifecycleRecyclerAdapter<GroupMemberL
bindRecipient(newGroupCandidate.getMember());
bindRecipientClick(newGroupCandidate.getMember());
itemView.setSelected(false);
newGroupCandidate.isSelected().observe(this, itemView::setSelected);
int smsWarningVisibility = newGroupCandidate.getMember().isRegistered() ? View.GONE : View.VISIBLE;
smsContact.setVisibility(smsWarningVisibility);

View File

@@ -0,0 +1,115 @@
package org.thoughtcrime.securesms.groups.ui.addmembers;
import android.content.Context;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.lifecycle.ViewModelProviders;
import org.thoughtcrime.securesms.ContactSelectionActivity;
import org.thoughtcrime.securesms.PushContactSelectionActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.libsignal.util.guava.Optional;
public class AddMembersActivity extends PushContactSelectionActivity {
public static final String GROUP_ID = "group_id";
private View done;
private AlertDialog alert;
private AddMembersViewModel viewModel;
@Override
protected void onCreate(Bundle icicle, boolean ready) {
getIntent().putExtra(ContactSelectionActivity.EXTRA_LAYOUT_RES_ID, R.layout.add_members_activity);
super.onCreate(icicle, ready);
AddMembersViewModel.Factory factory = new AddMembersViewModel.Factory(getGroupId());
done = findViewById(R.id.done);
alert = buildConfirmationAlertDialog();
viewModel = ViewModelProviders.of(this, factory)
.get(AddMembersViewModel.class);
viewModel.getAddMemberDialogState().observe(this, state -> AddMembersActivity.updateAlertMessage(alert, state));
//noinspection CodeBlock2Expr
done.setOnClickListener(v -> {
viewModel.setDialogStateForSelectedContacts(contactsFragment.getSelectedContacts());
alert.show();
});
disableDone();
}
@Override
protected void initializeToolbar() {
getToolbar().setNavigationIcon(R.drawable.ic_arrow_left_24);
getToolbar().setNavigationOnClickListener(v -> {
setResult(RESULT_CANCELED);
finish();
});
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() >= 1) {
enableDone();
}
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() < 1) {
disableDone();
}
}
private void enableDone() {
done.setEnabled(true);
done.animate().alpha(1f);
}
private void disableDone() {
done.setEnabled(false);
done.animate().alpha(0.5f);
}
private GroupId getGroupId() {
return GroupId.parseOrThrow(getIntent().getStringExtra(GROUP_ID));
}
private AlertDialog buildConfirmationAlertDialog() {
return new AlertDialog.Builder(this)
.setMessage(" ")
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel())
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
dialog.dismiss();
onFinishedSelection();
})
.setCancelable(true)
.create();
}
private static void updateAlertMessage(@NonNull AlertDialog alertDialog, @NonNull AddMembersViewModel.AddMemberDialogMessageState state) {
Context context = alertDialog.getContext();
Recipient recipient = Util.firstNonNull(state.getRecipient(), Recipient.UNKNOWN);
alertDialog.setMessage(context.getResources().getQuantityString(R.plurals.AddMembersActivity__add_d_members_to_s, state.getSelectionCount(),
recipient.getDisplayName(context), state.getGroupTitle(), state.getSelectionCount()));
}
}

View File

@@ -0,0 +1,24 @@
package org.thoughtcrime.securesms.groups.ui.addmembers;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
class AddMembersRepository {
private final Context context;
AddMembersRepository() {
this.context = ApplicationDependencies.getApplication();
}
void getOrCreateRecipientId(@NonNull SelectedContact selectedContact, @NonNull Consumer<RecipientId> consumer) {
SignalExecutors.BOUNDED.execute(() -> consumer.accept(selectedContact.getOrCreateRecipientId(context)));
}
}

View File

@@ -0,0 +1,142 @@
package org.thoughtcrime.securesms.groups.ui.addmembers;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.contacts.SelectedContact;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.List;
import java.util.Objects;
public final class AddMembersViewModel extends ViewModel {
private final AddMembersRepository repository;
private final LiveData<AddMemberDialogMessageState> addMemberDialogState;
private final MutableLiveData<AddMemberDialogMessageStatePartial> partialState;
private AddMembersViewModel(@NonNull GroupId groupId) {
repository = new AddMembersRepository();
partialState = new MutableLiveData<>();
addMemberDialogState = LiveDataUtil.combineLatest(Transformations.map(new LiveGroup(groupId).getTitle(), AddMembersViewModel::titleOrDefault),
Transformations.switchMap(partialState, AddMembersViewModel::getStateWithoutGroupTitle),
AddMembersViewModel::getStateWithGroupTitle);
}
LiveData<AddMemberDialogMessageState> getAddMemberDialogState() {
return addMemberDialogState;
}
void setDialogStateForSelectedContacts(@NonNull List<SelectedContact> selectedContacts) {
if (selectedContacts.size() == 1) {
setDialogStateForSingleRecipient(selectedContacts.get(0));
} else {
setDialogStateForMultipleRecipients(selectedContacts.size());
}
}
private void setDialogStateForSingleRecipient(@NonNull SelectedContact selectedContact) {
//noinspection CodeBlock2Expr
repository.getOrCreateRecipientId(selectedContact, recipientId -> {
partialState.postValue(new AddMemberDialogMessageStatePartial(recipientId));
});
}
private void setDialogStateForMultipleRecipients(int recipientCount) {
partialState.setValue(new AddMemberDialogMessageStatePartial(recipientCount));
}
private static LiveData<AddMemberDialogMessageState> getStateWithoutGroupTitle(@NonNull AddMemberDialogMessageStatePartial partialState) {
if (partialState.recipientId != null) {
return Transformations.map(Recipient.live(partialState.recipientId).getLiveData(), r -> new AddMemberDialogMessageState(r, ""));
} else {
return new DefaultValueLiveData<>(new AddMemberDialogMessageState(partialState.memberCount, ""));
}
}
private static AddMemberDialogMessageState getStateWithGroupTitle(@NonNull String title, @NonNull AddMemberDialogMessageState stateWithoutTitle) {
return new AddMemberDialogMessageState(stateWithoutTitle.recipient, stateWithoutTitle.selectionCount, title);
}
private static @NonNull String titleOrDefault(@Nullable String title) {
return TextUtils.isEmpty(title) ? ApplicationDependencies.getApplication().getString(R.string.Recipient_unknown)
: Objects.requireNonNull(title);
}
private static final class AddMemberDialogMessageStatePartial {
private final RecipientId recipientId;
private final int memberCount;
private AddMemberDialogMessageStatePartial(@NonNull RecipientId recipientId) {
this.recipientId = recipientId;
this.memberCount = 1;
}
private AddMemberDialogMessageStatePartial(int memberCount) {
Preconditions.checkArgument(memberCount > 1);
this.memberCount = memberCount;
this.recipientId = null;
}
}
public static final class AddMemberDialogMessageState {
private final Recipient recipient;
private final String groupTitle;
private final int selectionCount;
private AddMemberDialogMessageState(@NonNull Recipient recipient, @NonNull String groupTitle) {
this(recipient, 1, groupTitle);
}
private AddMemberDialogMessageState(int selectionCount, @NonNull String groupTitle) {
this(null, selectionCount, groupTitle);
}
private AddMemberDialogMessageState(@Nullable Recipient recipient, int selectionCount, @NonNull String groupTitle) {
this.recipient = recipient;
this.groupTitle = groupTitle;
this.selectionCount = selectionCount;
}
public Recipient getRecipient() {
return recipient;
}
public int getSelectionCount() {
return selectionCount;
}
public @NonNull String getGroupTitle() {
return groupTitle;
}
}
public static class Factory implements ViewModelProvider.Factory {
private final GroupId groupId;
public Factory(@NonNull GroupId groupId) {
this.groupId = groupId;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return Objects.requireNonNull(modelClass.cast(new AddMembersViewModel(groupId)));
}
}
}

View File

@@ -44,6 +44,8 @@ public class CreateGroupActivity extends ContactSelectionActivity {
: ContactsCursorLoader.DisplayMode.FLAG_PUSH;
intent.putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
intent.putExtra(ContactSelectionListFragment.TOTAL_CAPACITY, FeatureFlags.groupsV2create() ? FeatureFlags.gv2GroupCapacity() - 1
: ContactSelectionListFragment.NO_LIMIT);
return intent;
}
@@ -81,6 +83,10 @@ public class CreateGroupActivity extends ContactSelectionActivity {
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SIZE) {
enableNext();
}
@@ -88,6 +94,10 @@ public class CreateGroupActivity extends ContactSelectionActivity {
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() < MINIMUM_GROUP_SIZE) {
disableNext();
}

View File

@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.groups.ui.creategroup;
import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import android.widget.TextView;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.google.android.material.appbar.AppBarLayout;
import org.thoughtcrime.securesms.R;
import java.lang.ref.WeakReference;
public final class GroupSettingsCoordinatorLayoutBehavior extends CoordinatorLayout.Behavior<View> {
private static final Interpolator INTERPOLATOR = new DecelerateInterpolator();
private final ViewRef avatarTargetRef = new ViewRef(R.id.avatar_target);
private final ViewRef groupNameRef = new ViewRef(R.id.group_name);
private final ViewRef groupNameTargetRef = new ViewRef(R.id.group_name_target);
private final Rect targetRect = new Rect();
private final Rect childRect = new Rect();
public GroupSettingsCoordinatorLayoutBehavior(@NonNull Context context, @Nullable AttributeSet attrs) {
}
@Override
public boolean layoutDependsOn(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
return dependency instanceof AppBarLayout;
}
@Override
public boolean onDependentViewChanged(@NonNull CoordinatorLayout parent, @NonNull View child, @NonNull View dependency) {
AppBarLayout appBarLayout = (AppBarLayout) dependency;
int range = appBarLayout.getTotalScrollRange();
float factor = INTERPOLATOR.getInterpolation(-appBarLayout.getY() / range);
updateAvatarPositionAndScale(parent, child, factor);
updateNamePosition(parent, factor);
return true;
}
private void updateAvatarPositionAndScale(@NonNull CoordinatorLayout parent, @NonNull View child, float factor) {
View target = avatarTargetRef.require(parent);
targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom());
childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
float widthScale = 1f - (1f - (targetRect.width() / (float) childRect.width())) * factor;
float heightScale = 1f - (1f - (targetRect.height() / (float) childRect.height())) * factor;
float superimposedLeft = childRect.left + (childRect.width() - targetRect.width()) / 2f;
float superimposedTop = childRect.top + (childRect.height() - targetRect.height()) / 2f;
float xTranslation = (targetRect.left - superimposedLeft) * factor;
float yTranslation = (targetRect.top - superimposedTop) * factor;
child.setScaleX(widthScale);
child.setScaleY(heightScale);
child.setTranslationX(xTranslation);
child.setTranslationY(yTranslation);
}
private void updateNamePosition(@NonNull CoordinatorLayout parent, float factor) {
TextView child = (TextView) groupNameRef.require(parent);
View target = groupNameTargetRef.require(parent);
targetRect.set(target.getLeft(), target.getTop(), target.getRight(), target.getBottom());
childRect.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
if (child.getMaxWidth() != targetRect.width()) {
child.setMaxWidth(targetRect.width());
}
float deltaTop = targetRect.top - childRect.top;
float deltaStart = getStart(parent, targetRect) - getStart(parent, childRect);
float yTranslation = deltaTop * factor;
float xTranslation = deltaStart * factor;
child.setTranslationY(yTranslation);
child.setTranslationX(xTranslation);
}
private static int getStart(@NonNull CoordinatorLayout parent, @NonNull Rect rect) {
return parent.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? rect.left : rect.right;
}
private static final class ViewRef {
private WeakReference<View> ref = new WeakReference<>(null);
private final @IdRes int idRes;
private ViewRef(@IdRes int idRes) {
this.idRes = idRes;
}
private @NonNull View require(@NonNull View parent) {
View view = ref.get();
if (view == null) {
view = getChildOrThrow(parent, idRes);
ref = new WeakReference<>(view);
}
return view;
}
private static @NonNull View getChildOrThrow(@NonNull View parent, @IdRes int id) {
View child = parent.findViewById(id);
if (child == null) {
throw new AssertionError("Can't find view with ID " + R.id.avatar_target);
} else {
return child;
}
}
}
}

View File

@@ -63,7 +63,8 @@ public class AddGroupDetailsActivity extends PassphraseRequiredActionBarActivity
recipientId,
threadId,
ThreadDatabase.DistributionTypes.DEFAULT,
-1);
-1,
false);
startActivity(intent);
setResult(RESULT_OK);

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.groups.ui.creategroup.details;
import android.Manifest;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
@@ -20,6 +21,7 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProviders;
@@ -37,6 +39,7 @@ import org.thoughtcrime.securesms.mediasend.AvatarSelectionBottomSheetDialogFrag
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -58,38 +61,6 @@ public class AddGroupDetailsFragment extends Fragment {
private Drawable avatarPlaceholder;
private EditText name;
private Toolbar toolbar;
private ActionMode actionMode;
private ActionMode.Callback recipientActionModeCallback = new ActionMode.Callback() {
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
mode.getMenuInflater().inflate(R.menu.add_group_details_fragment_context_menu, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false;
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
if (item.getItemId() == R.id.action_delete) {
viewModel.deleteSelected();
mode.finish();
return true;
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
actionMode = null;
viewModel.clearSelected();
}
};
@Override
public void onAttach(@NonNull Context context) {
@@ -140,16 +111,19 @@ public class AddGroupDetailsFragment extends Fragment {
initializeViewModel();
avatar.setOnClickListener(v -> AvatarSelectionBottomSheetDialogFragment.create(false, true, REQUEST_CODE_AVATAR, true)
.show(getChildFragmentManager(), "BOTTOM"));
members.setRecipientLongClickListener(this::handleRecipientLongClick);
avatar.setOnClickListener(v -> showAvatarSelectionBottomSheet());
members.setRecipientClickListener(this::handleRecipientClick);
name.addTextChangedListener(new AfterTextChanged(editable -> viewModel.setName(editable.toString())));
toolbar.setNavigationOnClickListener(unused -> callback.onNavigationButtonPressed());
create.setOnClickListener(v -> handleCreateClicked());
viewModel.getMembers().observe(getViewLifecycleOwner(), members::setMembers);
viewModel.getCanSubmitForm().observe(getViewLifecycleOwner(), isFormValid -> setCreateEnabled(isFormValid, true));
viewModel.getIsMms().observe(getViewLifecycleOwner(), isMms -> mmsWarning.setVisibility(isMms ? View.VISIBLE : View.GONE));
viewModel.getIsMms().observe(getViewLifecycleOwner(), isMms -> {
mmsWarning.setVisibility(isMms ? View.VISIBLE : View.GONE);
name.setVisibility(isMms ? View.GONE : View.VISIBLE);
avatar.setVisibility(isMms ? View.GONE : View.VISIBLE);
toolbar.setTitle(isMms ? R.string.AddGroupDetailsFragment__create_group : R.string.AddGroupDetailsFragment__name_this_group);
});
viewModel.getAvatar().observe(getViewLifecycleOwner(), avatarBytes -> {
if (avatarBytes == null) {
avatar.setImageDrawable(new InsetDrawable(avatarPlaceholder, ViewUtil.dpToPx(AVATAR_PLACEHOLDER_INSET_DP)));
@@ -162,11 +136,19 @@ public class AddGroupDetailsFragment extends Fragment {
.into(avatar);
}
});
name.requestFocus();
}
@Override
public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
if (requestCode == REQUEST_CODE_AVATAR && resultCode == Activity.RESULT_OK && data != null) {
if (data.getBooleanExtra("delete", false)) {
viewModel.setAvatar(null);
return;
}
final Media result = data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA);
final DecryptableStreamUriLoader.DecryptableUri decryptableUri = new DecryptableStreamUriLoader.DecryptableUri(result.getUri());
@@ -211,29 +193,15 @@ public class AddGroupDetailsFragment extends Fragment {
}
private void handleRecipientClick(@NonNull Recipient recipient) {
if (actionMode == null) {
return;
}
int size = viewModel.toggleSelected(recipient);
if (size == 0) {
actionMode.finish();
}
}
private boolean handleRecipientLongClick(@NonNull Recipient recipient) {
if (actionMode != null) {
return false;
}
actionMode = toolbar.startActionMode(recipientActionModeCallback);
if (actionMode != null) {
viewModel.toggleSelected(recipient);
return true;
}
return false;
new AlertDialog.Builder(requireContext())
.setMessage(getString(R.string.AddGroupDetailsFragment__remove_s_from_this_group, recipient.getDisplayName(requireContext())))
.setCancelable(true)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.cancel())
.setPositiveButton(R.string.AddGroupDetailsFragment__remove, (dialog, which) -> {
viewModel.delete(recipient.getId());
dialog.dismiss();
})
.show();
}
private void handleGroupCreateResult(@NonNull GroupCreateResult groupCreateResult) {
@@ -280,6 +248,15 @@ public class AddGroupDetailsFragment extends Fragment {
.alpha(isEnabled ? 1f : 0.5f);
}
private void showAvatarSelectionBottomSheet() {
Permissions.with(this)
.request(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.onAnyResult(() -> AvatarSelectionBottomSheetDialogFragment.create(viewModel.hasAvatar(), true, REQUEST_CODE_AVATAR, true)
.show(getChildFragmentManager(), "BOTTOM"))
.execute();
}
public interface Callback {
void onGroupCreated(@NonNull RecipientId recipientId, long threadId);
void onNavigationButtonPressed();

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.creategroup.details;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
@@ -13,7 +14,6 @@ import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
@@ -27,27 +27,25 @@ import java.util.Set;
public final class AddGroupDetailsViewModel extends ViewModel {
private final LiveData<List<GroupMemberEntry.NewGroupCandidate>> members;
private final DefaultValueLiveData<Set<RecipientId>> selected = new DefaultValueLiveData<>(new HashSet<>());
private final DefaultValueLiveData<Set<RecipientId>> deleted = new DefaultValueLiveData<>(new HashSet<>());
private final MutableLiveData<String> name = new MutableLiveData<>("");
private final MutableLiveData<byte[]> avatar = new MutableLiveData<>();
private final LiveData<Boolean> isMms;
private final SingleLiveEvent<GroupCreateResult> groupCreateResult = new SingleLiveEvent<>();
private final LiveData<Boolean> canSubmitForm = Transformations.map(name, name -> !TextUtils.isEmpty(name));
private final LiveData<Boolean> isMms;
private final LiveData<Boolean> canSubmitForm;
private final AddGroupDetailsRepository repository;
AddGroupDetailsViewModel(@NonNull RecipientId[] recipientIds,
@NonNull AddGroupDetailsRepository repository)
private AddGroupDetailsViewModel(@NonNull RecipientId[] recipientIds,
@NonNull AddGroupDetailsRepository repository)
{
this.repository = repository;
MutableLiveData<List<GroupMemberEntry.NewGroupCandidate>> initialMembers = new MutableLiveData<>();
LiveData<List<GroupMemberEntry.NewGroupCandidate>> membersWithoutDeleted = LiveDataUtil.combineLatest(initialMembers,
deleted,
AddGroupDetailsViewModel::filterDeletedMembers);
MutableLiveData<List<GroupMemberEntry.NewGroupCandidate>> initialMembers = new MutableLiveData<>();
members = LiveDataUtil.combineLatest(membersWithoutDeleted, selected, AddGroupDetailsViewModel::updateSelectedMembers);
isMms = Transformations.map(members, this::isAnyForcedSms);
LiveData<Boolean> isValidName = Transformations.map(name, name -> !TextUtils.isEmpty(name));
members = LiveDataUtil.combineLatest(initialMembers, deleted, AddGroupDetailsViewModel::filterDeletedMembers);
isMms = Transformations.map(members, this::isAnyForcedSms);
canSubmitForm = LiveDataUtil.combineLatest(isMms, isValidName, (mms, validName) -> mms || validName);
repository.resolveMembers(recipientIds, initialMembers::postValue);
}
@@ -72,35 +70,22 @@ public final class AddGroupDetailsViewModel extends ViewModel {
return isMms;
}
void setAvatar(@NonNull byte[] avatar) {
void setAvatar(@Nullable byte[] avatar) {
this.avatar.setValue(avatar);
}
boolean hasAvatar() {
return avatar.getValue() != null;
}
void setName(@NonNull String name) {
this.name.setValue(name);
}
int toggleSelected(@NonNull Recipient recipient) {
Set<RecipientId> selected = this.selected.getValue();
if (!selected.add(recipient.getId())) {
selected.remove(recipient.getId());
}
this.selected.setValue(selected);
return selected.size();
}
void clearSelected() {
this.selected.setValue(new HashSet<>());
}
void deleteSelected() {
Set<RecipientId> selected = this.selected.getValue();
void delete(@NonNull RecipientId recipientId) {
Set<RecipientId> deleted = this.deleted.getValue();
deleted.addAll(selected);
deleted.add(recipientId);
this.deleted.setValue(deleted);
}
@@ -108,10 +93,10 @@ public final class AddGroupDetailsViewModel extends ViewModel {
List<GroupMemberEntry.NewGroupCandidate> members = Objects.requireNonNull(this.members.getValue());
Set<RecipientId> memberIds = Stream.of(members).map(member -> member.getMember().getId()).collect(Collectors.toSet());
byte[] avatarBytes = avatar.getValue();
String groupName = name.getValue();
boolean isGroupMms = isMms.getValue() == Boolean.TRUE;
String groupName = isGroupMms ? "" : name.getValue();
if (TextUtils.isEmpty(groupName)) {
if (!isGroupMms && TextUtils.isEmpty(groupName)) {
groupCreateResult.postValue(GroupCreateResult.error(GroupCreateResult.Error.Type.ERROR_INVALID_NAME));
return;
}
@@ -134,14 +119,6 @@ public final class AddGroupDetailsViewModel extends ViewModel {
.toList();
}
private static @NonNull List<GroupMemberEntry.NewGroupCandidate> updateSelectedMembers(@NonNull List<GroupMemberEntry.NewGroupCandidate> members, @NonNull Set<RecipientId> selected) {
for (GroupMemberEntry.NewGroupCandidate member : members) {
member.setSelected(selected.contains(member.getMember().getId()));
}
return members;
}
private boolean isAnyForcedSms(@NonNull List<GroupMemberEntry.NewGroupCandidate> members) {
return Stream.of(members)
.anyMatch(member -> !member.getMember().isRegistered());

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