Compare commits

..

141 Commits

Author SHA1 Message Date
Cody Henthorne
9bdcc9e3d8 Bump version to 6.22.7 2023-06-08 12:07:14 -04:00
Cody Henthorne
daee747338 Bump version to 6.22.6
Previous bump only incremented the version code.
2023-06-08 11:47:14 -04:00
Cody Henthorne
535a163f00 Bump version to 6.22.6 2023-06-08 10:58:12 -04:00
Clark
bd61b91722 Drop failed processed incoming messages. 2023-06-08 10:48:01 -04:00
Alex Hart
a0c1b072b6 Bump version to 6.22.5 2023-06-01 17:16:39 -03:00
Alex Hart
fe806cc4eb Updated baseline profile. 2023-06-01 16:52:55 -03:00
Nicholas Tinsley
aacad78cdb Delete redundant Bluetooth voice note codepath.
Added some additional logging, as well.
2023-06-01 16:40:33 -03:00
Cody Henthorne
b89f2dd862 Fix message notifier plural. 2023-06-01 16:40:33 -03:00
Alex Hart
36d01477cc Fix incoming call notifications. 2023-06-01 16:40:33 -03:00
Nicholas
e4090d00c9 Increase logging around image compression failures. 2023-05-31 16:59:54 -04:00
Alex Hart
ff55bc8209 Bump version to 6.22.4 2023-05-31 16:47:39 -03:00
Alex Hart
b375c9efdc Updated baseline profile. 2023-05-31 16:47:27 -03:00
Alex Hart
67b4fde5c3 Updated language translations. 2023-05-31 16:42:28 -03:00
Clark
63c6581d14 Fix transaction issues with thread update. 2023-05-31 14:46:25 -04:00
Nicholas
3ddd01981d Prevent crashing when forwarding edited media. 2023-05-31 14:45:56 -04:00
Alex Hart
bbd845905a Bump version to 6.22.3 2023-05-30 17:27:36 -03:00
Alex Hart
3f53c37187 Updated baseline profile. 2023-05-30 17:26:36 -03:00
Clark
159c0d1104 Fix child transaction causing batch to be discarded. 2023-05-30 15:18:05 -04:00
Nicholas Tinsley
82db08b76f Catch native RuntimeExceptions in voice memo recording start. 2023-05-30 15:07:58 -04:00
Nicholas Tinsley
83e84228f5 Bolster Bluetooth headset detection for Android 11 and older. 2023-05-30 15:00:49 -04:00
Clark
05edc715ef Fix thread update race for draft update. 2023-05-30 10:44:52 -04:00
Alex Hart
c503df5eec Bump version to 6.22.2 2023-05-26 15:55:12 -03:00
Alex Hart
1f242473fe Updated baseline profile. 2023-05-26 15:51:55 -03:00
Alex Hart
f7db5f8ae0 Updated language translations. 2023-05-26 15:46:39 -03:00
Greyson Parrelli
3b88d7cf94 Update notifications after transaction completes. 2023-05-26 14:23:07 -04:00
Nicholas Tinsley
cac82f2eba Make license screen content static. 2023-05-26 11:56:27 -04:00
Greyson Parrelli
5811b469cf Observe empty state on main thread. 2023-05-26 09:35:07 -04:00
Nicholas Tinsley
af1175f32e Update CameraX. 2023-05-25 18:03:45 -04:00
Nicholas Tinsley
7a5ce5761f Add tap to send debug log to account locked screen.
Addresses #12950.
2023-05-25 18:02:25 -04:00
Nicholas
34104355cb Bump version to 6.22.1 2023-05-25 17:12:21 -04:00
Nicholas
143c1255d8 Updated baseline profile. 2023-05-25 17:11:52 -04:00
Nicholas
a9d0e5ac81 Updated language translations. 2023-05-25 17:09:14 -04:00
Greyson Parrelli
c8b3ee51ed Acquire group lock before processing a message batch. 2023-05-25 16:07:26 -04:00
Cody Henthorne
c964067139 Fix placeholder in username string. 2023-05-25 15:59:32 -04:00
Nicholas Tinsley
539f590c4c Disconnect Bluetooth SCO when user cancels recording. 2023-05-25 11:57:13 -04:00
Nicholas Tinsley
71dddd4a1b Add some more Bluetooth connection logging. 2023-05-25 11:55:42 -04:00
Alex Hart
792f5dd7b5 Always display bottom bar. 2023-05-25 12:49:36 -03:00
Nicholas
a3af49d92a Bump version to 6.22.0 2023-05-24 12:15:26 -04:00
Nicholas
4289b43a81 Updated baseline profile. 2023-05-24 12:14:47 -04:00
Nicholas
7f55623acf Updated language translations. 2023-05-24 12:11:15 -04:00
Nicholas Tinsley
52060b65be Disable all icons other than the active one. 2023-05-24 12:05:23 -04:00
Clark
fd826749e4 Edit message design tweaks. 2023-05-24 12:05:23 -04:00
Clark
627e15c3dd Add thumbnail for when editing message with media. 2023-05-24 12:05:23 -04:00
Clark
90f6890180 Enqueue thread update job after transaction completes. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley
61eb397d2b Simplify notification for saving media.
Addresses #11759.
2023-05-24 12:05:23 -04:00
Greyson Parrelli
3a5e5364c7 Remove support for legacy gv1 sync messages. 2023-05-24 12:05:23 -04:00
Greyson Parrelli
25779d04a6 Regularly run account consistency checks. 2023-05-24 12:05:23 -04:00
Clark
242900e87f Dont requery attachments and add all jobs at once. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley
05f07d1788 Handle SmsRetriever initialization cancellation. 2023-05-24 12:05:23 -04:00
Alex Hart
f961f4ccac Add initial CFV2 long press state implementation. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley
145377b05f Add accessibility labels to navigate up button in ConversationFragment.
Addresses #12951.
2023-05-24 12:05:23 -04:00
Cody Henthorne
bc88887195 Animate CFv2 with keyboard opening or closing. 2023-05-24 12:05:23 -04:00
Nicholas Tinsley
5362b1c21c Prevent NPE when finishing voice memo recording. 2023-05-24 12:05:23 -04:00
Clark
0cfd3265ba Fix post transaction tasks not actually running. 2023-05-24 12:05:23 -04:00
Cody Henthorne
1099128513 Add rendering and handling for various disabled input states in CFv2. 2023-05-24 12:05:23 -04:00
Greyson Parrelli
ad50c81a6b Remove unnecessary validation check. 2023-05-24 12:05:23 -04:00
Clark
0817f113c6 Schedule media downloads after successful transaction. 2023-05-24 12:05:23 -04:00
Clark
4a9a07a9ef Run post transaction tasks only after root transaction ends. 2023-05-24 12:05:23 -04:00
Nicholas
61f50cfe60 Add license screen to settings page. 2023-05-24 12:05:23 -04:00
Nicholas
92888778c2 Restart websocket immediately upon network change. 2023-05-24 12:05:23 -04:00
Alex Hart
987f9b9dba Allow call links to exist in the calls tab. 2023-05-24 12:05:23 -04:00
Clark
97d95f37cc Rotate profile key when contact hidden. 2023-05-24 11:29:59 -04:00
Clark
836cd04564 Inline message processing when we can. 2023-05-24 11:29:59 -04:00
Clark
c26f54161d Use original message id for edit message history. 2023-05-24 11:29:59 -04:00
Clark
b540009ce6 Only call start foreground once from FCM. 2023-05-24 11:29:59 -04:00
Alex Hart
e58e209950 Remove ripple from tab buttons. 2023-05-24 11:29:58 -04:00
Alex Hart
4597a23104 Fix new call item margins. 2023-05-24 11:29:58 -04:00
Alex Hart
3aacf4bcd2 Add search highlight to call rows. 2023-05-24 11:29:58 -04:00
Alex Hart
6e6b663fac Reposition unread dots according to figma. 2023-05-24 11:29:58 -04:00
Alex Hart
6dad7eafcf Fix call tab color and spacing. 2023-05-24 11:29:58 -04:00
Alex Hart
5a38143987 Integrate call links create/update/read apis. 2023-05-24 11:29:58 -04:00
Greyson Parrelli
4d6d31d624 Make attachment count/size remote configurable. 2023-05-24 11:29:58 -04:00
Greyson Parrelli
938c82be3f Inline the calls tab feature flag. 2023-05-24 11:29:58 -04:00
Greyson Parrelli
dc2e249566 Add QR scanning to username link flow. 2023-05-24 11:29:58 -04:00
Greyson Parrelli
bb8fdcabcb Update to libsignal 0.25.0 2023-05-24 11:29:58 -04:00
Greyson Parrelli
6cf4dbc78c Add pre-alpha support for SVR2. 2023-05-24 11:29:58 -04:00
Jim Gustafson
8cd0ac5451 Update to RingRTC v2.27.0 2023-05-24 11:29:58 -04:00
Clark
33745f0b0c Fix edit message showing twice in notifications. 2023-05-24 11:29:58 -04:00
Nicholas
29ffed219f Bump version to 6.21.3 2023-05-24 10:52:40 -04:00
Nicholas
9629d0f715 Updated baseline profile. 2023-05-24 10:51:42 -04:00
Nicholas
01651984d7 Updated language translations. 2023-05-24 10:36:15 -04:00
Nicholas Tinsley
c7c15250ca Clarify app icon string descriptions. 2023-05-24 10:00:38 -04:00
Greyson Parrelli
36b96eafcc Bump version to 6.21.2 2023-05-19 16:37:26 -04:00
Greyson Parrelli
94f930ee22 Updated language translations. 2023-05-19 16:37:12 -04:00
Greyson Parrelli
3f740d2904 Tweak network timeout settings. 2023-05-19 16:30:19 -04:00
Nicholas Tinsley
f8529adfcf Design tweaks for app icon switching. 2023-05-19 16:30:19 -04:00
Alex Hart
77ccbdd322 Deduplicate in migration to prevent constraint breakage. 2023-05-19 16:30:19 -04:00
Cody Henthorne
f2846efd2c Fix split second spoiler reveal when quoting a message with a spoiler. 2023-05-19 16:30:19 -04:00
Nicholas Tinsley
131f9c4bc9 Move app icon composables outside of mutable Fragment class.
This way, the composables do not receive an implicit mutable parameter, which allows the compiler to mark them as skippable.
2023-05-19 16:30:19 -04:00
Greyson Parrelli
d7c06fff50 Bump version to 6.21.1 2023-05-18 20:35:52 -04:00
Greyson Parrelli
09a22d9dc4 Updated language translations. 2023-05-18 20:35:52 -04:00
Greyson Parrelli
0fbab04253 Animate transitions in icon selection. 2023-05-18 20:35:51 -04:00
Nicholas
c963e99dca Introduce the ability to change the app icon. 2023-05-18 20:35:51 -04:00
Alex Hart
7a555d127f Tighten migration and remove null peer events. 2023-05-18 20:35:51 -04:00
Cody Henthorne
866408f673 Limit body ranges processed on received messages. 2023-05-18 20:35:51 -04:00
Greyson Parrelli
a7e5ab1a6a Update inbound attachment processing. 2023-05-18 12:22:35 -04:00
Alex Hart
14d16d61e6 Only update text fields if contents changed. 2023-05-18 10:27:17 -03:00
Greyson Parrelli
b988e4a813 Disable foreign key constraints during backup restore updgrades. 2023-05-17 18:44:23 -04:00
Greyson Parrelli
ae33c8db1b Bump version to 6.21.0 2023-05-17 15:50:42 -04:00
Greyson Parrelli
9f1c5ac6bb Updated language translations. 2023-05-17 15:45:48 -04:00
Alex Hart
448e7d0739 Don't collapsed missed calls with outgoing or incoming. 2023-05-17 15:30:27 -04:00
Clark
8971ff9057 Fix for ForegroundServiceDidNotStartInTimeException for FcmFetchForegroundService. 2023-05-17 15:30:26 -04:00
Clark
2d6b16b2ce Introduce extra caching for group message processing. 2023-05-17 15:30:26 -04:00
Greyson Parrelli
44ab1643fa Fix group membership recipient remapping. 2023-05-17 15:30:26 -04:00
Cody Henthorne
a64bffd83a Complete text formatting. 2023-05-17 15:30:26 -04:00
Clark
534c5c3c64 Try not blocking main threads to start foreground service. 2023-05-17 15:30:26 -04:00
Cody Henthorne
99d3f9918f Fix crash and bug with ellipsizing 'About' in Settings screen.
Fixes #12895
Closes #12905
2023-05-17 15:30:26 -04:00
Ehren Kret
aaebf029db Remove unused capabilities. 2023-05-17 15:30:26 -04:00
Greyson Parrelli
e2c2ace0e3 Add initial storage interfaces for kyber prekeys. 2023-05-17 15:30:26 -04:00
Nicholas Tinsley
c76002663f Push bubbled conversation onto back stack. 2023-05-17 15:30:26 -04:00
Nicholas Tinsley
c5317370c8 Prevent duplicate reactions bottom sheet. 2023-05-17 15:30:26 -04:00
Cody Henthorne
4b09f4a654 Add basic attachment keyboard support to CFv2. 2023-05-17 15:30:26 -04:00
Alex Hart
0c57113d8e Add migration for call link recipient link. 2023-05-17 15:30:26 -04:00
Alex Hart
d7dd77a5af Add additional logging around thumbnail loading. 2023-05-17 15:30:26 -04:00
Greyson Parrelli
5c5b88ebcc Pluralize some strings. 2023-05-17 15:30:26 -04:00
Alex Hart
8df0248d4f Utilize receipts instead of messagerecord count for views. 2023-05-17 15:30:26 -04:00
Alex Hart
58e48fdf14 Fix switch toggling after first toggle. 2023-05-17 15:30:26 -04:00
Alex Hart
407fc56218 Utilize conversationRecipient for displaying whom a payment is to or from in conversation. 2023-05-17 15:30:26 -04:00
Alex Hart
398527d3f1 Ignore table update when remap already exists. 2023-05-17 15:30:26 -04:00
Greyson Parrelli
59745a695c Add context to some strings. 2023-05-17 15:30:26 -04:00
Cody Henthorne
2aaeda6ca8 Add initial send support to CFv2. 2023-05-17 15:30:26 -04:00
Alex Hart
5c78de2f46 Update nullability of CallLinkTable column. 2023-05-17 15:30:09 -04:00
Clark
93efc21452 Propogate read sync message to latest revision. 2023-05-17 15:30:09 -04:00
Clark
50ad005e7c Update edit message history dialog to match designs. 2023-05-17 15:30:09 -04:00
Clark
71c3bcdd29 Convert MSL delete entries for recipient to collection query. 2023-05-17 15:30:08 -04:00
Clark
e5bf04a407 Cleanup Recipient.externalPush to use RecipientId cache. 2023-05-17 15:30:08 -04:00
Clark
fe8b2cb761 Reduce db operations when updating thread snippet. 2023-05-17 15:30:08 -04:00
Clark
7c37f929a5 Go back to enqueuing thread update job. 2023-05-17 15:30:08 -04:00
Cody Henthorne
1b82d10b39 Squelch notifications in noisy groups and during large initial message processing. 2023-05-17 15:30:08 -04:00
Clark
6b6e2490e7 Add performance logging for message processing. 2023-05-17 15:30:08 -04:00
Greyson Parrelli
ebee54cf92 Add button to grow query area in Spinner. 2023-05-17 15:30:08 -04:00
Greyson Parrelli
b7acfb0dcc Add a better editor to Spinner. 2023-05-17 15:30:08 -04:00
Greyson Parrelli
65dab45582 Update string for sticker uninstall button label. 2023-05-17 15:30:08 -04:00
Greyson Parrelli
43086f9582 Fix more validation spots around unknown UUIDs.
This was a legacy path that got missed.
2023-05-17 15:30:08 -04:00
Clark
ed1aa74aff Reduce number of thread snippet updates from receipts. 2023-05-17 15:30:08 -04:00
Cody Henthorne
3ba128793a Display thread header in CFv2. 2023-05-17 15:30:08 -04:00
Clark
ffbbdc1576 Add PushProcessMessageJobV2 to reserved job queue. 2023-05-17 15:30:08 -04:00
Clark
6ff55cfff7 Reduce expensive group operations during message processing. 2023-05-17 15:29:31 -04:00
Clark
e9f1f781e1 Reduce number of db calls to getGroup. 2023-05-17 15:29:31 -04:00
Greyson Parrelli
6da36fe098 Deprecate the SyncMessage.pniIdentity field. 2023-05-17 15:29:31 -04:00
Greyson Parrelli
acb6510312 Switch to libsignal for PIN hashing. 2023-05-17 15:29:30 -04:00
441 changed files with 23041 additions and 3793 deletions

2
.gitignore vendored
View File

@@ -28,4 +28,4 @@ jni/libspeex/.deps/
pkcs11.password
dev.keystore
maps.key
local/
local/

View File

@@ -46,15 +46,15 @@ ktlint {
version = "0.47.1"
}
def canonicalVersionCode = 1262
def canonicalVersionName = "6.20.6"
def canonicalVersionCode = 1272
def canonicalVersionName = "6.22.7"
def postFixSize = 100
def abiPostFix = ['universal' : 5,
'armeabi-v7a' : 6,
'arm64-v8a' : 7,
'x86' : 8,
'x86_64' : 9]
def abiPostFix = ['universal' : 10,
'armeabi-v7a' : 11,
'arm64-v8a' : 12,
'x86' : 13,
'x86_64' : 14]
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
@@ -185,6 +185,7 @@ android {
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
@@ -200,6 +201,7 @@ android {
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"dc9fd472a5a9c871a3c7f76f1af60aa9c1f314abf2e8d1e0c4ba25c8aaa2848c\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
@@ -208,6 +210,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
@@ -383,6 +386,7 @@ android {
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
@@ -463,6 +467,7 @@ dependencies {
implementation libs.androidx.legacy.preference
implementation libs.androidx.gridlayout
implementation libs.androidx.exifinterface
implementation libs.androidx.compose.rxjava3
implementation libs.androidx.constraintlayout
implementation libs.androidx.multidex
implementation libs.androidx.navigation.fragment.ktx
@@ -520,12 +525,6 @@ dependencies {
exclude group: 'com.google.protobuf'
}
implementation(libs.signal.argon2) {
artifact {
type = "aar"
}
}
implementation libs.signal.ringrtc
implementation libs.leolin.shortcutbadger
@@ -563,6 +562,7 @@ dependencies {
}
implementation libs.dnsjava
implementation libs.kotlinx.collections.immutable
implementation libs.accompanist.permissions
spinnerImplementation project(":spinner")

View File

@@ -21,7 +21,6 @@ class CallTableTest {
@Test
fun givenACall_whenISetTimestamp_thenIExpectUpdatedTimestamp() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -30,8 +29,8 @@ class CallTableTest {
now
)
SignalDatabase.calls.setTimestamp(callId, conversationId, -1L)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
SignalDatabase.calls.setTimestamp(callId, harness.others[0], -1L)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(-1L, call?.timestamp)
@@ -43,7 +42,6 @@ class CallTableTest {
@Test
fun givenPreExistingEvent_whenIDeleteGroupCall_thenIMarkDeletedAndSetTimestamp() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
val now = System.currentTimeMillis()
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -52,10 +50,10 @@ class CallTableTest {
now
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
SignalDatabase.calls.deleteGroupCall(call!!)
val deletedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val deletedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
assertEquals(CallTable.Event.DELETE, deletedCall?.event)
@@ -66,7 +64,6 @@ class CallTableTest {
@Test
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId,
harness.others[0],
@@ -74,7 +71,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
@@ -87,7 +84,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenIInsertAcceptedOutgoingGroupCall_thenIExpectLocalRingerAndOutgoingRing() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -95,7 +91,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
assertEquals(harness.self.id, call?.ringerRecipient)
@@ -105,7 +101,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenIInsertAcceptedIncomingGroupCall_thenIExpectJoined() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -113,7 +108,7 @@ class CallTableTest {
System.currentTimeMillis()
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.JOINED, call?.event)
assertNull(call?.ringerRecipient)
@@ -123,7 +118,6 @@ class CallTableTest {
@Test
fun givenARingingCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -132,7 +126,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
@@ -140,14 +134,13 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenAMissedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -156,7 +149,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
@@ -164,14 +157,13 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@Test
fun givenADeclinedCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
ringId = callId,
groupRecipientId = harness.others[0],
@@ -180,7 +172,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
@@ -188,7 +180,7 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.ACCEPTED, acceptedCall?.event)
}
@@ -196,7 +188,6 @@ class CallTableTest {
fun givenAGenericGroupCall_whenIAcceptedIncomingGroupCall_thenIExpectAccepted() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -206,7 +197,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
@@ -214,7 +205,7 @@ class CallTableTest {
call!!
)
val acceptedCall = SignalDatabase.calls.getCallById(callId, conversationId)
val acceptedCall = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertEquals(CallTable.Event.JOINED, acceptedCall?.event)
}
@@ -222,7 +213,6 @@ class CallTableTest {
fun givenNoPriorCallEvent_whenIReceiveAGroupCallUpdateMessage_thenIExpectAGenericGroupCall() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -232,7 +222,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
}
@@ -242,7 +232,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -252,7 +241,7 @@ class CallTableTest {
isCallFull = false
)
SignalDatabase.calls.getCallById(callId, conversationId).let {
SignalDatabase.calls.getCallById(callId, harness.others[0]).let {
assertNotNull(it)
assertEquals(now, it?.timestamp)
}
@@ -266,7 +255,7 @@ class CallTableTest {
isCallFull = false
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.GENERIC_GROUP_CALL, call?.event)
assertEquals(1L, call?.timestamp)
@@ -275,7 +264,6 @@ class CallTableTest {
@Test
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
callId = callId,
recipientId = harness.others[0],
@@ -291,7 +279,7 @@ class CallTableTest {
ringState = CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DELETE, call?.event)
}
@@ -301,7 +289,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -319,7 +306,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -330,7 +317,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -346,7 +332,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -357,7 +343,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -375,7 +360,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -386,7 +371,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -412,7 +396,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -423,7 +407,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -439,7 +422,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -449,7 +432,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
harness.others[0],
@@ -465,7 +447,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -475,7 +457,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -501,7 +482,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@@ -511,7 +492,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -537,7 +517,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
}
@@ -547,7 +527,6 @@ class CallTableTest {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val now = System.currentTimeMillis()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromLocalEvent(
groupRecipientId = harness.others[0],
sender = harness.others[1],
@@ -565,7 +544,7 @@ class CallTableTest {
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
}
@@ -574,7 +553,6 @@ class CallTableTest {
fun givenARingingCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
@@ -592,7 +570,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@@ -601,7 +579,6 @@ class CallTableTest {
fun givenAMissedCallEvent_whenRingDeclinedOnAnotherDevice_thenIMoveToDeclinedState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
@@ -619,7 +596,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
}
@@ -628,7 +605,6 @@ class CallTableTest {
fun givenAnOutgoingRingCallEvent_whenRingDeclinedOnAnotherDevice_thenIDoNotChangeState() {
val era = "aaa"
val callId = CallId.fromEra(era).longValue()
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertAcceptedGroupCall(
callId,
@@ -645,7 +621,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.OUTGOING_RING, call?.event)
}
@@ -653,7 +629,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingRequested_thenICreateAnEventInTheRingingStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -662,7 +637,7 @@ class CallTableTest {
CallManager.RingUpdate.REQUESTED
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.RINGING, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -672,7 +647,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingExpired_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -681,7 +655,7 @@ class CallTableTest {
CallManager.RingUpdate.EXPIRED_REQUEST
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -691,7 +665,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledByRinger_thenICreateAnEventInTheMissedStateAndSetRinger() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -700,7 +673,7 @@ class CallTableTest {
CallManager.RingUpdate.CANCELLED_BY_RINGER
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertEquals(harness.others[1], call?.ringerRecipient)
@@ -710,7 +683,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyLocally_thenICreateAnEventInTheMissedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -719,7 +691,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_LOCALLY
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
@@ -728,7 +700,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingCancelledBecauseUserIsBusyOnAnotherDevice_thenICreateAnEventInTheMissedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -737,7 +708,7 @@ class CallTableTest {
CallManager.RingUpdate.BUSY_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.MISSED, call?.event)
assertNotNull(call?.messageId)
@@ -746,7 +717,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingAcceptedOnAnotherDevice_thenICreateAnEventInTheAcceptedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -755,7 +725,7 @@ class CallTableTest {
CallManager.RingUpdate.ACCEPTED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.ACCEPTED, call?.event)
assertNotNull(call?.messageId)
@@ -764,7 +734,6 @@ class CallTableTest {
@Test
fun givenNoPriorEvent_whenRingDeclinedOnAnotherDevice_thenICreateAnEventInTheDeclinedState() {
val callId = 1L
val conversationId = CallTable.CallConversationId.Peer(harness.others[0])
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(
callId,
harness.others[0],
@@ -773,7 +742,7 @@ class CallTableTest {
CallManager.RingUpdate.DECLINED_ON_ANOTHER_DEVICE
)
val call = SignalDatabase.calls.getCallById(callId, conversationId)
val call = SignalDatabase.calls.getCallById(callId, harness.others[0])
assertNotNull(call)
assertEquals(CallTable.Event.DECLINED, call?.event)
assertNotNull(call?.messageId)

View File

@@ -111,6 +111,7 @@ class DatabaseConsistencyTest {
.split("\n")
.map { it.trim() }
.joinToString(separator = " ")
.replace(Regex.fromLiteral(" ,"), ",")
.replace(Regex("\\s+"), " ")
.replace(Regex.fromLiteral("( "), "(")
.replace(Regex.fromLiteral(" )"), ")")

View File

@@ -122,6 +122,37 @@ class GroupTableTest {
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
}
@Test
fun givenAGroup_whenIRemapRecipientsThatHaveAConflict_thenIExpectDeletion() {
val v2Group = insertPushGroupWithSelfAndOthers(
listOf(
harness.others[0],
harness.others[1]
)
)
insertThread(v2Group)
groupTable.remapRecipient(harness.others[0], harness.others[1])
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.members.toSet())
}
@Test
fun givenAGroup_whenIRemapRecipients_thenIExpectRemap() {
val v2Group = insertPushGroup()
insertThread(v2Group)
val newId = harness.others[1]
groupTable.remapRecipient(harness.others[0], newId)
val groupRecord = groupTable.getGroup(v2Group).get()
assertEquals(setOf(harness.self.id, newId), groupRecord.members.toSet())
}
@Test
fun givenAGroupAndMember_whenIIsCurrentMember_thenIExpectTrue() {
val v2Group = insertPushGroup()
@@ -280,6 +311,31 @@ class GroupTableTest {
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)
return groupTable.create(groupMasterKey, decryptedGroupState)!!
}
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
val otherMembers: List<DecryptedMember> = others.map { id ->
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
}
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(listOf(selfMember) + otherMembers)
.setRevision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!
}
}

View File

@@ -180,4 +180,45 @@ class SQLiteDatabaseTest {
assertTrue(hasRun1.get())
assertTrue(hasRun2.get())
}
@Test
fun runPostSuccessfulTransaction_runsAfterMainTransactionInNestedTransaction() {
val hasRun1 = AtomicBoolean(false)
val hasRun2 = AtomicBoolean(false)
db.beginTransaction()
db.runPostSuccessfulTransaction {
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
hasRun1.set(true)
}
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.beginTransaction()
db.runPostSuccessfulTransaction {
assertTrue(hasRun1.get())
assertFalse(hasRun2.get())
hasRun2.set(true)
}
db.setTransactionSuccessful()
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.endTransaction()
db.setTransactionSuccessful()
assertFalse(hasRun1.get())
assertFalse(hasRun2.get())
db.endTransaction()
assertTrue(hasRun1.get())
assertTrue(hasRun2.get())
}
}

View File

@@ -82,10 +82,12 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
emptyArray(),
emptyList(),
Optional.of(SignalServiceNetworkAccess.DNS),
Optional.empty(),
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS),
Base64.decode(BuildConfig.GENERIC_SERVER_PUBLIC_PARAMS)
)
serviceNetworkAccessMock = mock {

View File

@@ -5,9 +5,10 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.signal.core.util.Hex;
import org.whispersystems.signalservice.api.kbs.HashedPin;
import org.signal.libsignal.svr2.PinHash;
import org.whispersystems.signalservice.api.kbs.KbsData;
import org.whispersystems.signalservice.api.kbs.MasterKey;
import org.whispersystems.signalservice.api.kbs.PinHashUtil;
import java.io.IOException;
@@ -24,16 +25,16 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("ab7e8499d21f80a6600b3b9ee349ac6d72c07e3359fe885a934ba7aa844429f8"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("3f33ce58eb25b40436592a30eae2a8fabab1899095f4e2fba6e2d0dc43b4a2d9cac5a3931748522393951e0e54dec769"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -42,16 +43,16 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("88a787415a2ecd79da0d1016a82a27c5c695c9a19b88b0aa1d35683280aa9a67"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("301d9dd1e96f20ce51083f67d3298fd37b97525de8324d5e12ed2d407d3d927b"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("9d9b05402ea39c17ff1c9298c8a0e86784a352aa02a74943bf8bcf07ec0f4b574a5b786ad0182c8d308d9eb06538b8c9"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -60,18 +61,18 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("9571f3fde1e58588ba49bcf82be1b301ca3859a6f59076f79a8f47181ef952bf"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("ab645acdccc1652a48a34b2ac6926340ff35c03034013f68760f20013f028dd8"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("11c0ba1834db15e47c172f6c987c64bd4cfc69c6047dd67a022afeec0165a10943f204d5b8f37b3cb7bab21c6dfc39c8"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
assertEquals("577939bccb2b6638c39222d5a97998a867c5e154e30b82cc120f2dd07a3de987", kbsData.getMasterKey().deriveRegistrationLock());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
@Test
@@ -80,18 +81,17 @@ public final class PinHashing_hashPin_Test {
byte[] backupId = Hex.fromStringCondensed("717dc111a98423a57196512606822fca646c653facd037c10728f14ba0be2ab3");
MasterKey masterKey = new MasterKey(Hex.fromStringCondensed("0432d735b32f66d0e3a70d4f9cc821a8529521a4937d26b987715d8eff4e4c54"));
HashedPin hashedPin = PinHashing.hashPin(pin, () -> backupId);
KbsData kbsData = hashedPin.createNewKbsData(masterKey);
PinHash hashedPin = PinHashUtil.hashPin(pin, new byte[]{});
KbsData kbsData = PinHashUtil.createNewKbsData(hashedPin, masterKey);
assertArrayEquals(hashedPin.getKbsAccessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(hashedPin.accessKey(), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("d2fedabd0d4c17a371491c9722578843a26be3b4923e28d452ab2fc5491e794b"), kbsData.getKbsAccessKey());
assertArrayEquals(Hex.fromStringCondensed("877ef871ef1fc668401c717ef21aa12e8523579fb1ff4474b76f28c2293537c80cc7569996c9e0229bea7f378e3a824e"), kbsData.getCipherText());
assertEquals(masterKey, kbsData.getMasterKey());
assertEquals("23a75cb1df1a87df45cc2ed167c2bdc85ab1220b847c88761b0005cac907fce5", kbsData.getMasterKey().deriveRegistrationLock());
String localPinHash = PinHashing.localPinHash(pin);
assertTrue(PinHashing.verifyLocalPinHash(localPinHash, pin));
String localPinHash = PinHashUtil.localPinHash(pin);
assertTrue(PinHashUtil.verifyLocalPinHash(localPinHash, pin));
}
}

View File

@@ -111,7 +111,7 @@ class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorT
decryptedGroupState
)
val groupRecipient = Recipient.externalGroupExact(group)
val groupRecipient = Recipient.externalGroupExact(group!!)
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val insertResult = MmsHelper.insert(

View File

@@ -9,6 +9,7 @@ import org.signal.libsignal.protocol.SignalProtocolAddress
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.protocol.groups.state.SenderKeyRecord
import org.signal.libsignal.protocol.state.IdentityKeyStore
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.signal.libsignal.protocol.state.PreKeyBundle
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.state.SessionRecord
@@ -155,6 +156,11 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun storeSignedPreKey(signedPreKeyId: Int, record: SignedPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsSignedPreKey(signedPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun removeSignedPreKey(signedPreKeyId: Int) = throw UnsupportedOperationException()
override fun loadKyberPreKey(kyberPreKeyId: Int): KyberPreKeyRecord = throw UnsupportedOperationException()
override fun loadKyberPreKeys(): MutableList<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun storeKyberPreKey(kyberPreKeyId: Int, record: KyberPreKeyRecord?) = throw UnsupportedOperationException()
override fun containsKyberPreKey(kyberPreKeyId: Int): Boolean = throw UnsupportedOperationException()
override fun markKyberPreKeyUsed(kyberPreKeyId: Int) = throw UnsupportedOperationException()
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()

View File

@@ -48,7 +48,7 @@ object FakeClientHelpers {
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized)).targetUnidentifiedAccess
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {

View File

@@ -31,7 +31,7 @@ object GroupTestingUtils {
.setTitle(MessageContentFuzzer.string())
.build()
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
val groupRecipientId = SignalDatabase.recipients.getOrInsertFromGroupId(groupId)
SignalDatabase.recipients.setProfileSharing(groupRecipientId, true)

View File

@@ -11,6 +11,7 @@ import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.protocol.util.Medium
import org.signal.libsignal.svr2.PinHash
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -19,7 +20,6 @@ import org.thoughtcrime.securesms.pin.TokenData
import org.thoughtcrime.securesms.test.BuildConfig
import org.whispersystems.signalservice.api.KbsPinData
import org.whispersystems.signalservice.api.KeyBackupService
import org.whispersystems.signalservice.api.kbs.HashedPin
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.push.ServiceId
@@ -97,7 +97,7 @@ object MockProvider {
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
override fun restorePin(hashedPin: HashedPin?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
override fun restorePin(hashedPin: PinHash?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
}
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)

View File

@@ -257,6 +257,307 @@
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltYellow"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_yellow"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_yellow" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_yellow" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltBubbles"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_bubbles"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_bubbles" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_bubbles" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltChat"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_chat"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_chat" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_chat" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltNews"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_news"
android:label="@string/app_icon_label_news"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_news" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_news" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltNotes"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_notes"
android:label="@string/app_icon_label_notes"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_notes" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_notes" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltColor"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_color"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_color" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_color" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltDark"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_dark"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltDarkVariant"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_dark_variant"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_dark_variant" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWhite"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_signal_white"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_signal_white" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_signal_white" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWaves"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_waves"
android:label="@string/app_icon_label_waves"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_waves" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_waves" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity-alias
android:name=".RoutingActivityAltWeather"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_alt_weather"
android:label="@string/app_icon_label_weather"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
</intent-filter>
<meta-data
android:name="com.sec.minimode.icon.portrait.normal"
android:resource="@mipmap/ic_launcher_alt_weather" />
<meta-data
android:name="com.sec.minimode.icon.landscape.normal"
android:resource="@mipmap/ic_launcher_alt_weather" />
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity-alias>
<activity android:name=".deeplinks.DeepLinkEntryActivity"
android:exported="true"
android:noHistory="true"
@@ -298,6 +599,14 @@
<data android:scheme="sgnl"
android:host="signal.me" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https"
android:host="signal.link" />
</intent-filter>
</activity>
<activity android:name=".conversation.v2.ConversationActivity"
@@ -364,6 +673,11 @@
android:windowSoftInputMode="stateAlwaysVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.links.details.CallLinkDetailsActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".calls.new.NewCallActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysVisible"

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,7 @@ object AppCapabilities {
@JvmStatic
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
uuid = false,
gv2 = true,
storage = storageCapable,
gv1Migration = true,
senderKey = true,
announcementGroup = true,
changeNumber = true,

View File

@@ -48,6 +48,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmJobService;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
@@ -211,6 +212,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
.addPostRender(() -> ApplicationDependencies.getExoPlayerPool().getPoolStats().getMaxUnreserved())
.addPostRender(() -> ApplicationDependencies.getRecipientCache().warmUp())
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");

View File

@@ -115,5 +115,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks);
}
}

View File

@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.stories.Stories;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -26,7 +25,6 @@ import org.thoughtcrime.securesms.util.CachedInflater;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.SplashScreenUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
@@ -82,6 +80,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
handleCallLinkInIntent(getIntent());
CachedInflater.from(this).clear();
@@ -148,14 +147,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
private void updateTabVisibility() {
if (Stories.isFeatureEnabled() || FeatureFlags.callsTab()) {
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
} else {
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
conversationListTabsViewModel.onChatsSelected();
}
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
}
public @NonNull MainNavigator getNavigator() {
@@ -183,6 +176,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleCallLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
CommunicationActions.handlePotentialCallLinkUrl(this, data.toString());
}
}
public void onFirstRender() {
onFirstRender = true;
}

View File

@@ -89,7 +89,7 @@ public class AudioRecorder {
}
recorder.start(fds[1]);
this.recordingSubject = recordingSingle;
} catch (IOException e) {
} catch (IOException | RuntimeException e) {
recordingSingle.onError(e);
recorder = null;
Log.w(TAG, e);

View File

@@ -50,6 +50,7 @@ private class BluetoothVoiceNoteUtil31(val listener: () -> Unit) : BluetoothVoic
}
override fun disconnectBluetoothScoConnection() {
Log.d(TAG, "Clearing call manager communication device.")
ApplicationDependencies.getAndroidCallAudioManager().clearCommunicationDevice()
}
@@ -80,46 +81,31 @@ private class BluetoothVoiceNoteUtilLegacy(val context: Context, val listener: (
private var hasWarnedAboutBluetooth = false
init {
if (Build.VERSION.SDK_INT < 31) {
audioHandler.post {
signalBluetoothManager.start()
Log.d(TAG, "Bluetooth manager started.")
}
audioHandler.post {
signalBluetoothManager.start()
Log.d(TAG, "Bluetooth manager started.")
}
}
override fun connectBluetoothScoConnection() {
if (Build.VERSION.SDK_INT >= 31) {
val audioManager = ApplicationDependencies.getAndroidCallAudioManager()
val device: AudioDeviceInfo? = audioManager.connectedBluetoothDevice
if (device != null) {
val result: Boolean = audioManager.setCommunicationDevice(device)
if (result) {
Log.d(TAG, "Successfully set Bluetooth device as active communication device.")
} else {
Log.d(TAG, "Found Bluetooth device but failed to set it as active communication device.")
}
} else {
Log.d(TAG, "Could not find Bluetooth device in list of communications devices, falling back to current input.")
audioHandler.post {
if (signalBluetoothManager.state.shouldUpdate()) {
Log.d(TAG, "Bluetooth manager updating devices.")
signalBluetoothManager.updateDevice()
}
listener()
} else {
audioHandler.post {
if (signalBluetoothManager.state.shouldUpdate()) {
signalBluetoothManager.updateDevice()
}
val currentState = signalBluetoothManager.state
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
signalBluetoothManager.startScoAudio()
} else {
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
uiThreadHandler.post {
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
bluetoothPermissionDeniedHandler()
hasWarnedAboutBluetooth = true
}
listener()
val currentState = signalBluetoothManager.state
if (currentState == SignalBluetoothManager.State.AVAILABLE) {
Log.d(TAG, "Bluetooth manager state is AVAILABLE. Starting SCO connection.")
signalBluetoothManager.startScoAudio()
} else {
Log.d(TAG, "Recording from phone mic because bluetooth state was " + currentState + ", not " + SignalBluetoothManager.State.AVAILABLE)
uiThreadHandler.post {
if (currentState == SignalBluetoothManager.State.PERMISSION_DENIED && !hasWarnedAboutBluetooth) {
Log.d(TAG, "Warning about Bluetooth permissions.")
bluetoothPermissionDeniedHandler()
hasWarnedAboutBluetooth = true
}
listener()
}
}
}

View File

@@ -1,8 +1,83 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.Hex
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import java.net.URLDecoder
/**
* Utility object for call links to try to keep some common logic in one place.
*/
object CallLinks {
fun url(identifier: String) = "https://calls.signal.org/#$identifier"
private const val ROOT_KEY = "key"
private val TAG = Log.tag(CallLinks::class.java)
fun url(linkKeyBytes: ByteArray) = "https://signal.link/call/#key=${Hex.dump(linkKeyBytes)}"
fun watchCallLink(roomId: CallLinkRoomId): Observable<CallLinkTable.CallLink> {
return Observable.create { emitter ->
fun refresh() {
val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId)
if (callLink != null) {
emitter.onNext(callLink)
}
}
val observer = DatabaseObserver.Observer {
refresh()
}
ApplicationDependencies.getDatabaseObserver().registerCallLinkObserver(roomId, observer)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
refresh()
}
}
@JvmStatic
fun parseUrl(url: String): CallLinkRootKey? {
val parts = url.split("#")
if (parts.size != 2) {
Log.w(TAG, "Invalid fragment delimiter count in url.")
return null
}
val fragment = parts[1]
val fragmentParts = fragment.split("&")
val fragmentQuery = fragmentParts.associate {
val kv = it.split("=")
if (kv.size != 2) {
Log.w(TAG, "Invalid fragment keypair. Skipping.")
}
val key = URLDecoder.decode(kv[0], "utf8")
val value = URLDecoder.decode(kv[1], "utf8")
key to value
}
val key = fragmentQuery[ROOT_KEY]
if (key == null) {
Log.w(TAG, "Root key not found in fragment query string.")
return null
}
// TODO Parse the key into a byte array
return null
}
}

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import android.app.Dialog

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import androidx.compose.foundation.Image
@@ -30,21 +35,34 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import java.time.Instant
@Preview
@Composable
private fun SignalCallRowPreview() {
val avatarColor = remember { AvatarColor.random() }
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
name = "Call Name",
identifier = "blahblahblah",
avatarColor = avatarColor,
isApprovalRequired = false
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
restrictions = org.signal.ringrtc.CallLinkState.Restrictions.NONE,
expiration = Instant.MAX,
revoked = false
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
@@ -95,10 +113,10 @@ fun SignalCallRow(
.align(CenterVertically)
) {
Text(
text = callLink.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = CallLinks.url(callLink.identifier),
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
/**
* Repository for performing update operations on call links:
* <ul>
* <li>Set name</li>
* <li>Set restrictions</li>
* <li>Revoke link</li>
* </ul>
*
* All of these will delegate to the [SignalCallLinkManager] but will additionally update the database state.
*/
class UpdateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun setCallName(credentials: CallLinkCredentials, name: String): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkName(
credentials = credentials,
name = name
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun setCallRestrictions(credentials: CallLinkCredentials, restrictions: CallLinkState.Restrictions): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRestrictions(
credentials = credentials,
restrictions = restrictions
)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
fun revokeCallLink(credentials: CallLinkCredentials): Single<UpdateCallLinkResult> {
return callLinkManager
.updateCallLinkRevoked(credentials, true)
.doOnSuccess(updateState(credentials))
.subscribeOn(Schedulers.io())
}
private fun updateState(credentials: CallLinkCredentials): (UpdateCallLinkResult) -> Unit {
return { result ->
if (result is UpdateCallLinkResult.Success) {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
}
}
}
}

View File

@@ -1,3 +1,8 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import android.content.ActivityNotFoundException
@@ -28,9 +33,13 @@ import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
@@ -39,7 +48,10 @@ import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.sharing.MultiShareArgs
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
/**
@@ -47,14 +59,20 @@ import org.thoughtcrime.securesms.util.Util
*/
class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
companion object {
private val TAG = Log.tag(CreateCallLinkBottomSheetDialogFragment::class.java)
}
private val viewModel: CreateCallLinkViewModel by viewModels()
private val lifecycleDisposable = LifecycleDisposable()
override val peekHeightPercentage: Float = 1f
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setCallName(bundle.getString(resultKey)!!)
setCallName(bundle.getString(resultKey)!!)
}
}
}
@@ -94,10 +112,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
)
Rows.ToggleRow(
checked = callLink.isApprovalRequired,
checked = callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__approve_all_members),
onCheckChanged = viewModel::setApproveAllMembers,
modifier = Modifier.clickable(onClick = viewModel::toggleApproveAllMembers)
onCheckChanged = this@CreateCallLinkBottomSheetDialogFragment::setApproveAllMembers,
modifier = Modifier.clickable(onClick = this@CreateCallLinkBottomSheetDialogFragment::toggleApproveAllMembers)
)
Dividers.Default()
@@ -133,54 +151,133 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
}
}
private fun setCallName(callName: String) {
lifecycleDisposable += viewModel.setCallName(callName).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link name")
toastFailure()
}
}, onError = this::handleError)
}
private fun setApproveAllMembers(approveAllMembers: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(approveAllMembers).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun toggleApproveAllMembers() {
lifecycleDisposable += viewModel.toggleApproveAllMembers().subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to update call link restrictions")
toastFailure()
}
}, onError = this::handleError)
}
private fun onAddACallNameClicked() {
val snapshot = viewModel.callLink.value
findNavController().navigate(
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.name)
CreateCallLinkBottomSheetDialogFragmentDirections.actionCreateCallLinkBottomSheetToEditCallLinkNameDialogFragment(snapshot.state.name)
)
}
private fun onJoinClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
CommunicationActions.startVideoCall(requireActivity(), it.recipient)
dismissAllowingStateLoss()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onDoneClicked() {
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> dismissAllowingStateLoss()
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareViaSignalClicked() {
val snapshot = viewModel.callLink.value
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(CallLinks.url(viewModel.linkKeyBytes))
.build()
)
)
)
}
MultiselectForwardFragment.showFullScreen(
childFragmentManager,
MultiselectForwardFragmentArgs(
canSendToNonPush = false,
multiShareArgs = listOf(
MultiShareArgs.Builder()
.withDraftText(snapshot.identifier)
.build()
)
)
)
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onCopyLinkClicked() {
val snapshot = viewModel.callLink.value
Util.copyToClipboard(requireContext(), CallLinks.url(snapshot.identifier))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
lifecycleDisposable += viewModel.commitCallLink().subscribeBy(onSuccess = {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.linkKeyBytes))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
is EnsureCallLinkCreatedResult.Failure -> handleCreateCallLinkFailure(it.failure)
}
}, onError = this::handleError)
}
private fun onShareLinkClicked() {
val snapshot = viewModel.callLink.value
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(snapshot.identifier))
.setType(mimeType)
.createChooserIntent()
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
lifecycleDisposable += viewModel.commitCallLink().subscribeBy {
when (it) {
is EnsureCallLinkCreatedResult.Success -> {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.linkKeyBytes))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
is EnsureCallLinkCreatedResult.Failure -> {
Log.w(TAG, "Failed to create link: $it")
toastFailure()
}
}
}
}
private fun handleCreateCallLinkFailure(failure: CreateCallLinkResult.Failure) {
Log.w(TAG, "Failed to create call link: $failure")
toastFailure()
}
private fun handleError(throwable: Throwable) {
Log.w(TAG, "Failed to create call link.", throwable)
toastFailure()
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}

View File

@@ -0,0 +1,62 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
/**
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
* but will also ensure the database is updated.
*/
class CreateCallLinkRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun ensureCallLinkCreated(credentials: CallLinkCredentials, avatarColor: AvatarColor): Single<EnsureCallLinkCreatedResult> {
val callLinkRecipientId = Single.fromCallable {
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId)
}
return callLinkRecipientId.flatMap { recipientId ->
if (recipientId.isPresent) {
Single.just(EnsureCallLinkCreatedResult.Success(Recipient.resolved(recipientId.get())))
} else {
callLinkManager.createCallLink(credentials).map {
when (it) {
is CreateCallLinkResult.Success -> {
SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = it.state,
avatarColor = avatarColor
)
)
EnsureCallLinkCreatedResult.Success(
Recipient.resolved(
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()
)
)
}
is CreateCallLinkResult.Failure -> EnsureCallLinkCreatedResult.Failure(it)
}
}
}
}.subscribeOn(Schedulers.io())
}
}

View File

@@ -1,27 +1,98 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import java.time.Instant
class CreateCallLinkViewModel : ViewModel() {
class CreateCallLinkViewModel(
private val repository: CreateCallLinkRepository = CreateCallLinkRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val credentials = CallLinkCredentials.generate()
private val avatarColor = AvatarColor.random()
private val _callLink: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.random(), false)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "",
restrictions = Restrictions.NONE,
revoked = false,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
)
val callLink: State<CallLinkTable.CallLink> = _callLink
val linkKeyBytes: ByteArray = credentials.linkKeyBytes
fun setApproveAllMembers(approveAllMembers: Boolean) {
_callLink.value = _callLink.value.copy(isApprovalRequired = approveAllMembers)
private val disposables = CompositeDisposable()
init {
disposables += CallLinks.watchCallLink(credentials.roomId)
.subscribeBy {
_callLink.value = it
}
}
fun toggleApproveAllMembers() {
_callLink.value = _callLink.value.copy(isApprovalRequired = _callLink.value.isApprovalRequired)
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setCallName(callName: String) {
_callLink.value = _callLink.value.copy(name = callName)
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
return repository.ensureCallLinkCreated(credentials, avatarColor)
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallRestrictions(
credentials,
if (approveAllMembers) Restrictions.ADMIN_APPROVAL else Restrictions.NONE
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
}
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
return commitCallLink()
.flatMap {
when (it) {
is EnsureCallLinkCreatedResult.Success -> mutationRepository.setCallName(
credentials,
callName
)
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
}
}

View File

@@ -0,0 +1,14 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.create
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
sealed interface EnsureCallLinkCreatedResult {
data class Success(val recipient: Recipient) : EnsureCallLinkCreatedResult
data class Failure(val failure: CreateCallLinkResult.Failure) : EnsureCallLinkCreatedResult
}

View File

@@ -1,10 +1,28 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details)
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
companion object {
private const val BUNDLE = "bundle"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
}
}
}

View File

@@ -1,7 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
@@ -16,19 +24,34 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.setFragmentResultListener
import androidx.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.CommunicationActions
import java.time.Instant
/**
* Provides detailed info about a call link and allows the owner of that link
@@ -36,53 +59,115 @@ import org.thoughtcrime.securesms.database.CallLinkTable
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
private val viewModel: CallLinkViewModel by viewModels()
companion object {
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
}
private val args: CallLinkDetailsFragmentArgs by navArgs()
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(args.roomId)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
viewModel.setName(bundle.getString(resultKey)!!)
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun FragmentContent() {
val isLoading by viewModel.isLoading
val callLink by viewModel.callLink
val state by viewModel.state
CallLinkDetails(
isLoading,
callLink,
state,
this
)
}
override fun onNavigationClicked() {
findNavController().popBackStack()
ActivityCompat.finishAfterTransition(requireActivity())
}
override fun onJoinClicked() {
// TODO("Not yet implemented")
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(this, recipientSnapshot)
}
}
override fun onEditNameClicked() {
val name = viewModel.callLink.value.name
val name = viewModel.nameSnapshot
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
// TODO("Not yet implemented")
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onDeleteClicked() {
// TODO("Not yet implemented")
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.revoke().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Success -> ActivityCompat.finishAfterTransition(requireActivity())
else -> {
Log.w(TAG, "Failed to revoke. $it")
toastFailure()
}
}
}, onError = handleError("onDeleteClicked"))
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
// TODO("Not yet implemented")
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
}, onError = handleError("setName"))
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}
@@ -92,6 +177,8 @@ private interface CallLinkDetailsCallback {
fun onEditNameClicked()
fun onShareClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
fun onApproveAllMembersChanged(checked: Boolean)
}
@@ -103,19 +190,30 @@ private fun CallLinkDetailsPreview() {
}
val callLink = remember {
val credentials = CallLinkCredentials.generate()
CallLinkTable.CallLink(
name = "Call Name",
identifier = "call-id-1",
isApprovalRequired = false,
recipientId = RecipientId.UNKNOWN,
roomId = credentials.roomId,
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
avatarColor = avatarColor
)
}
SignalTheme(false) {
CallLinkDetails(
false,
callLink,
CallLinkDetailsState(
false,
callLink
),
object : CallLinkDetailsCallback {
override fun onDeleteConfirmed() = Unit
override fun onDeleteCanceled() = Unit
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
@@ -129,8 +227,7 @@ private fun CallLinkDetailsPreview() {
@Composable
private fun CallLinkDetails(
isLoading: Boolean,
callLink: CallLinkTable.CallLink,
state: CallLinkDetailsState,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
@@ -138,13 +235,13 @@ private fun CallLinkDetails(
onNavigationClick = callback::onNavigationClicked,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24)
) { paddingValues ->
if (isLoading) {
if (state.callLink == null) {
return@Settings
}
Column(modifier = Modifier.padding(paddingValues)) {
SignalCallRow(
callLink = callLink,
callLink = state.callLink,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
@@ -155,7 +252,7 @@ private fun CallLinkDetails(
)
Rows.ToggleRow(
checked = callLink.isApprovalRequired,
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)
@@ -175,5 +272,16 @@ private fun CallLinkDetails(
modifier = Modifier.clickable(onClick = callback::onDeleteClicked)
)
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}

View File

@@ -0,0 +1,44 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.ReadCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
class CallLinkDetailsRepository(
private val callLinkManager: SignalCallLinkManager = ApplicationDependencies.getSignalCallManager().callLinkManager
) {
fun refreshCallLinkState(callLinkRoomId: CallLinkRoomId): Disposable {
return Maybe.fromCallable<CallLinkTable.CallLink> { SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) }
.flatMapSingle { callLinkManager.readCallLink(it.credentials!!) }
.subscribeOn(Schedulers.io())
.subscribeBy { result ->
when (result) {
is ReadCallLinkResult.Success -> SignalDatabase.callLinks.updateCallLinkState(callLinkRoomId, result.callLinkState)
is ReadCallLinkResult.Failure -> Unit
}
}
}
fun watchCallLinkRecipient(callLinkRoomId: CallLinkRoomId): Observable<Recipient> {
return Maybe.fromCallable<RecipientId> { SignalDatabase.recipients.getByCallLinkRoomId(callLinkRoomId).orNull() }
.flatMapObservable { Recipient.observable(it) }
.distinctUntilChanged { a, b -> a.hasSameContent(b) }
.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val callLink: CallLinkTable.CallLink? = null
)

View File

@@ -0,0 +1,84 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLinkDetailsViewModel(
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private val _state: MutableState<CallLinkDetailsState> = mutableStateOf(CallLinkDetailsState())
val state: State<CallLinkDetailsState> = _state
val nameSnapshot: String
get() = state.value.callLink?.state?.name ?: error("Call link not loaded yet.")
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
private val recipientSubject = BehaviorSubject.create<Recipient>()
val recipientSnapshot: Recipient?
get() = recipientSubject.value
init {
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
disposables += repository
.watchCallLinkRecipient(callLinkRoomId)
.subscribeBy(onNext = recipientSubject::onNext)
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun setDisplayRevocationDialog(displayRevocationDialog: Boolean) {
_state.value = _state.value.copy(displayRevocationDialog = displayRevocationDialog)
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
}
fun revoke(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.revokeCallLink(credentials)
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(CallLinkDetailsViewModel(callLinkRoomId)) as T
}
}
}

View File

@@ -1,22 +0,0 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.database.CallLinkTable
class CallLinkViewModel : ViewModel() {
private val isLoadingState: MutableState<Boolean> = mutableStateOf(true)
val isLoading: State<Boolean> = isLoadingState
private val callLinkState: MutableState<CallLinkTable.CallLink> = mutableStateOf(
CallLinkTable.CallLink("", "", AvatarColor.A120, false)
)
val callLink: State<CallLinkTable.CallLink> = callLinkState
fun setName(name: String) {
callLinkState.value = callLinkState.value.copy(name = name)
}
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.calls.log
import android.content.res.ColorStateList
import android.text.style.TextAppearanceSpan
import android.view.View
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@@ -15,6 +16,7 @@ import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBin
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.SearchUtil
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
@@ -60,6 +62,14 @@ class CallLogAdapter(
inflater = CallLogCreateCallLinkItemBinding::inflate
)
)
registerFactory(
CallLinkModel::class.java,
BindingFactory(
creator = { CallLinkModelViewHolder(it, callbacks::onCallLinkClicked, callbacks::onCallLinkLongClicked, callbacks::onStartVideoCallClicked) },
inflater = CallLogAdapterItemBinding::inflate
)
)
}
fun submitCallRows(
@@ -74,6 +84,7 @@ class CallLogAdapter(
.map {
when (it) {
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
is CallLogRow.CallLink -> CallLinkModel(it, selectionState, itemCount)
is CallLogRow.ClearFilter -> ClearFilterModel()
is CallLogRow.CreateCallLink -> CreateCallLinkModel()
}
@@ -118,6 +129,44 @@ class CallLogAdapter(
}
}
private class CallLinkModel(
val callLink: CallLogRow.CallLink,
val selectionState: CallLogSelectionState,
val itemCount: Int
) : MappingModel<CallLinkModel> {
companion object {
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
}
override fun areItemsTheSame(newItem: CallLinkModel): Boolean {
return callLink.record.roomId == newItem.callLink.record.roomId
}
override fun areContentsTheSame(newItem: CallLinkModel): Boolean {
return callLink == newItem.callLink &&
isSelectionStateTheSame(newItem) &&
isItemCountTheSame(newItem)
}
override fun getChangePayload(newItem: CallLinkModel): Any? {
return if (callLink == newItem.callLink && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
CallModel.PAYLOAD_SELECTION_STATE
} else {
null
}
}
private fun isSelectionStateTheSame(newItem: CallLinkModel): Boolean {
return selectionState.contains(callLink.id) == newItem.selectionState.contains(newItem.callLink.id) &&
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
}
private fun isItemCountTheSame(newItem: CallLinkModel): Boolean {
return itemCount == newItem.itemCount
}
}
private class ClearFilterModel : MappingModel<ClearFilterModel> {
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
@@ -129,6 +178,54 @@ class CallLogAdapter(
override fun areContentsTheSame(newItem: CreateCallLinkModel): Boolean = true
}
private class CallLinkModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
itemView.setOnClickListener {
onCallLinkClicked(model.callLink)
}
itemView.setOnLongClickListener {
onCallLinkLongClicked(itemView, model.callLink)
}
itemView.isSelected = model.selectionState.contains(model.callLink.id)
binding.callSelected.isChecked = model.selectionState.contains(model.callLink.id)
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
return
}
binding.callRecipientAvatar.setAvatar(model.callLink.recipient)
val callLinkName = model.callLink.record.state.name.takeIf { it.isNotEmpty() }
?: context.getString(R.string.WebRtcCallView__signal_call)
binding.callRecipientName.text = SearchUtil.getHighlightedSpan(
Locale.getDefault(),
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
callLinkName,
model.callLink.searchQuery,
SearchUtil.MATCH_ALL
)
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
}
}
private class CallModelViewHolder(
binding: CallLogAdapterItemBinding,
private val onCallClicked: (CallLogRow.Call) -> Unit,
@@ -153,13 +250,27 @@ class CallLogAdapter(
return
}
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
presentRecipientDetails(model.call.peer, model.call.searchQuery)
presentCallInfo(model.call, model.call.date)
presentCallType(model)
}
private fun presentRecipientDetails(recipient: Recipient, searchQuery: String?) {
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), recipient, true)
binding.callRecipientBadge.setBadgeFromRecipient(recipient)
binding.callRecipientName.text = if (searchQuery != null) {
SearchUtil.getHighlightedSpan(
Locale.getDefault(),
{ arrayOf(TextAppearanceSpan(context, R.style.Signal_Text_TitleSmall)) },
recipient.getDisplayName(context),
searchQuery,
SearchUtil.MATCH_ALL
)
} else {
recipient.getDisplayName(context)
}
}
private fun presentCallInfo(call: CallLogRow.Call, date: Long) {
val callState = context.getString(getCallStateStringRes(call.record))
binding.callInfo.text = context.getString(
@@ -181,7 +292,7 @@ class CallLogAdapter(
if (call.record.event == CallTable.Event.MISSED) {
R.color.signal_colorError
} else {
R.color.signal_colorOnSurface
R.color.signal_colorOnSurfaceVariant
}
)
@@ -219,6 +330,7 @@ class CallLogAdapter(
binding.callType.visible = true
binding.groupCallButton.visible = false
}
CallLogRow.GroupCallState.ACTIVE, CallLogRow.GroupCallState.LOCAL_USER_JOINED -> {
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -249,6 +361,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.type}")
}
}
@@ -269,6 +382,7 @@ class CallLogAdapter(
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.messageType}")
}
}
@@ -308,11 +422,21 @@ class CallLogAdapter(
*/
fun onCallClicked(callLogRow: CallLogRow.Call)
/**
* Invoked when a call link row is clicked
*/
fun onCallLinkClicked(callLogRow: CallLogRow.CallLink)
/**
* Invoked when a call row is long-clicked
*/
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
/**
* Invoked when a call link row is long-clicked
*/
fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean
/**
* Invoked when the clear filter button is pressed
*/
@@ -321,11 +445,11 @@ class CallLogAdapter(
/**
* Invoked when user presses the audio icon
*/
fun onStartAudioCallClicked(peer: Recipient)
fun onStartAudioCallClicked(recipient: Recipient)
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(peer: Recipient)
fun onStartVideoCallClicked(recipient: Recipient)
}
}

View File

@@ -5,11 +5,13 @@ import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CommunicationActions
/**
@@ -30,28 +32,47 @@ class CallLogContextMenu(
}
.show(
listOfNotNull(
getVideoCallActionItem(call),
getVideoCallActionItem(call.peer),
getAudioCallActionItem(call),
getGoToChatActionItem(call),
getInfoActionItem(call),
getInfoActionItem(call.peer, (call.id as CallLogRow.Id.Call).children.toLongArray()),
getSelectActionItem(call),
getDeleteActionItem(call)
)
)
}
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
fun show(recyclerView: RecyclerView, anchor: View, callLink: CallLogRow.CallLink) {
recyclerView.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.onDismiss {
anchor.isSelected = false
recyclerView.suppressLayout(false)
}
.show(
listOfNotNull(
getVideoCallActionItem(callLink.recipient),
getInfoActionItem(callLink.recipient, longArrayOf()),
getSelectActionItem(callLink),
getDeleteActionItem(callLink)
)
)
}
private fun getVideoCallActionItem(peer: Recipient): ActionItem {
// TODO [alex] -- Need group calling disposition to make this correct
return ActionItem(
iconRes = R.drawable.symbol_video_24,
title = fragment.getString(R.string.CallContextMenu__video_call)
) {
CommunicationActions.startVideoCall(fragment, call.peer)
CommunicationActions.startVideoCall(fragment, peer)
}
}
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
if (call.peer.isGroup) {
if (call.peer.isCallLink || call.peer.isGroup) {
return null
}
@@ -63,26 +84,32 @@ class CallLogContextMenu(
}
}
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem? {
return when {
call.peer.isCallLink -> null
else -> ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
) {
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
}
}
}
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
private fun getInfoActionItem(peer: Recipient, messageIds: LongArray): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_info_24,
title = fragment.getString(R.string.CallContextMenu__info)
) {
val intent = ConversationSettingsActivity.forCall(fragment.requireContext(), call.peer, longArrayOf(call.record.messageId!!))
val intent = when {
peer.isCallLink -> CallLinkDetailsActivity.createIntent(fragment.requireContext(), peer.requireCallLinkRoomId())
else -> ConversationSettingsActivity.forCall(fragment.requireContext(), peer, messageIds)
}
fragment.startActivity(intent)
}
}
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
private fun getSelectActionItem(call: CallLogRow): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
@@ -91,8 +118,8 @@ class CallLogContextMenu(
}
}
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
if (call.record.event == CallTable.Event.ONGOING) {
private fun getDeleteActionItem(call: CallLogRow): ActionItem? {
if (call is CallLogRow.Call && call.record.event == CallTable.Event.ONGOING) {
return null
}
@@ -105,7 +132,7 @@ class CallLogContextMenu(
}
interface Callbacks {
fun startSelection(call: CallLogRow.Call)
fun deleteCall(call: CallLogRow.Call)
fun startSelection(call: CallLogRow)
fun deleteCall(call: CallLogRow)
}
}

View File

@@ -28,6 +28,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.new.NewCallActivity
import org.thoughtcrime.securesms.components.Material3SearchToolbar
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
@@ -311,9 +312,23 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
override fun onCallClicked(callLogRow: CallLogRow.Call) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
val intent = ConversationSettingsActivity.forCall(requireContext(), callLogRow.peer, (callLogRow.id as CallLogRow.Id.Call).children.toLongArray())
} else if (!callLogRow.peer.isCallLink) {
val intent = ConversationSettingsActivity.forCall(
requireContext(),
callLogRow.peer,
(callLogRow.id as CallLogRow.Id.Call).children.toLongArray()
)
startActivity(intent)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.peer.requireCallLinkRoomId()))
}
}
override fun onCallLinkClicked(callLogRow: CallLogRow.CallLink) {
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
}
}
@@ -322,25 +337,30 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
return true
}
override fun onCallLinkLongClicked(itemView: View, callLinkLogRow: CallLogRow.CallLink): Boolean {
callLogContextMenu.show(binding.recycler, itemView, callLinkLogRow)
return true
}
override fun onClearFilterClicked() {
binding.pullView.toggle()
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
}
override fun onStartAudioCallClicked(peer: Recipient) {
CommunicationActions.startVoiceCall(this, peer)
override fun onStartAudioCallClicked(recipient: Recipient) {
CommunicationActions.startVoiceCall(this, recipient)
}
override fun onStartVideoCallClicked(peer: Recipient) {
CommunicationActions.startVideoCall(this, peer)
override fun onStartVideoCallClicked(recipient: Recipient) {
CommunicationActions.startVideoCall(this, recipient)
}
override fun startSelection(call: CallLogRow.Call) {
override fun startSelection(call: CallLogRow) {
callLogActionMode.start()
viewModel.toggleSelected(call.id)
}
override fun deleteCall(call: CallLogRow.Call) {
override fun deleteCall(call: CallLogRow) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
.setPositiveButton(R.string.CallLogFragment__delete_for_me) { _, _ ->

View File

@@ -12,28 +12,62 @@ class CallLogPagedDataSource(
private val hasFilter = filter == CallLogFilter.MISSED
private val hasCallLinkRow = FeatureFlags.adHocCalling() && filter == CallLogFilter.ALL && query.isNullOrEmpty()
private var callsCount = 0
private var callEventsCount = 0
private var callLinksCount = 0
override fun size(): Int {
callsCount = repository.getCallsCount(query, filter)
return callsCount + hasFilter.toInt() + hasCallLinkRow.toInt()
callEventsCount = repository.getCallsCount(query, filter)
callLinksCount = repository.getCallLinksCount(query, filter)
return callEventsCount + callLinksCount + hasFilter.toInt() + hasCallLinkRow.toInt()
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val calls = mutableListOf<CallLogRow>()
val callLimit = length - hasCallLinkRow.toInt()
if (start == 0 && length >= 1 && hasCallLinkRow) {
calls.add(CallLogRow.CreateCallLink)
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
val callLogRows = mutableListOf<CallLogRow>()
if (length <= 0) {
return callLogRows
}
calls.addAll(repository.getCalls(query, filter, start, callLimit).toMutableList())
val callLinkStart = if (hasCallLinkRow) 1 else 0
val callEventStart = callLinkStart + callLinksCount
val clearFilterStart = callEventStart + callEventsCount
if (calls.size < length && hasFilter) {
calls.add(CallLogRow.ClearFilter)
var remaining = length
if (start < callLinkStart) {
callLogRows.add(CallLogRow.CreateCallLink)
remaining -= 1
}
return calls
if (start < callEventStart && remaining > 0) {
val callLinks = repository.getCallLinks(
query,
filter,
start,
remaining
)
callLogRows.addAll(callLinks)
remaining -= callLinks.size
}
if (start < clearFilterStart && remaining > 0) {
val callEvents = repository.getCalls(
query,
filter,
start - callLinksCount,
remaining
)
callLogRows.addAll(callEvents)
remaining -= callEvents.size
}
if (start <= clearFilterStart && remaining > 0) {
callLogRows.add(CallLogRow.ClearFilter)
}
return callLogRows
}
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
@@ -47,5 +81,7 @@ class CallLogPagedDataSource(
interface CallRepository {
fun getCallsCount(query: String?, filter: CallLogFilter): Int
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
}
}

View File

@@ -17,6 +17,20 @@ class CallLogRepository : CallLogPagedDataSource.CallRepository {
return SignalDatabase.calls.getCalls(start, length, query, filter)
}
override fun getCallLinksCount(query: String?, filter: CallLogFilter): Int {
return when (filter) {
CallLogFilter.MISSED -> 0
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinksCount(query)
}
}
override fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
return when (filter) {
CallLogFilter.MISSED -> emptyList()
CallLogFilter.ALL -> SignalDatabase.callLinks.getCallLinks(query, start, length)
}
}
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
SignalDatabase.messages.markAllCallEventsRead()

View File

@@ -1,8 +1,15 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.log
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* A row to be displayed in the call log
@@ -11,6 +18,16 @@ sealed class CallLogRow {
abstract val id: Id
/**
* A call link with no "active" events.
*/
data class CallLink(
val record: CallLinkTable.CallLink,
val recipient: Recipient,
val searchQuery: String?,
override val id: Id = Id.CallLink(record.roomId)
) : CallLogRow()
/**
* An incoming, outgoing, or missed call.
*/
@@ -20,6 +37,7 @@ sealed class CallLogRow {
val date: Long,
val groupCallState: GroupCallState,
val children: Set<Long>,
val searchQuery: String?,
override val id: Id = Id.Call(children)
) : CallLogRow()
@@ -36,6 +54,7 @@ sealed class CallLogRow {
sealed class Id {
data class Call(val children: Set<Long>) : Id()
data class CallLink(val roomId: CallLinkRoomId) : Id()
object ClearFilter : Id()
object CreateCallLink : Id()
}

View File

@@ -96,7 +96,7 @@ class CallLogViewModel(
}
@MainThread
fun stageCallDeletion(call: CallLogRow.Call) {
fun stageCallDeletion(call: CallLogRow) {
callLogStore.state.stagedDeletion?.commit()
callLogStore.update {
it.copy(

View File

@@ -302,9 +302,7 @@ public class ComposeText extends EmojiEditText {
addTextChangedListener(mentionValidatorWatcher);
if (FeatureFlags.textFormatting()) {
if (FeatureFlags.textFormattingSpoilerSend()) {
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
}
spoilerRendererDelegate = new SpoilerRendererDelegate(this, true);
addTextChangedListener(new ComposeTextStyleWatcher());
@@ -323,10 +321,7 @@ public class ComposeText extends EmojiEditText {
menu.add(0, R.id.edittext_italic, largestOrder, getContext().getString(R.string.TextFormatting_italic));
menu.add(0, R.id.edittext_strikethrough, largestOrder, getContext().getString(R.string.TextFormatting_strikethrough));
menu.add(0, R.id.edittext_monospace, largestOrder, getContext().getString(R.string.TextFormatting_monospace));
if (FeatureFlags.textFormattingSpoilerSend()) {
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
}
menu.add(0, R.id.edittext_spoiler, largestOrder, getContext().getString(R.string.TextFormatting_spoiler));
return true;
}

View File

@@ -8,10 +8,13 @@ import androidx.annotation.Nullable;
import com.bumptech.glide.request.target.DrawableImageViewTarget;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
private static final String TAG = Log.tag(GlideDrawableListeningTarget.class);
private final SettableFuture<Boolean> loaded;
public GlideDrawableListeningTarget(@NonNull ImageView view, @NonNull SettableFuture<Boolean> loaded) {
@@ -21,6 +24,12 @@ public class GlideDrawableListeningTarget extends DrawableImageViewTarget {
@Override
protected void setResource(@Nullable Drawable resource) {
if (resource == null) {
Log.d(TAG, "Loaded null resource");
} else {
Log.d(TAG, "Loaded resource of w " + resource.getIntrinsicWidth() + " by h " + resource.getIntrinsicHeight());
}
super.setResource(resource);
loaded.set(true);
}

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.Context
import android.util.AttributeSet
import android.widget.EditText
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our
* own input fragment.
*/
class InputAwareConstraintLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) {
private var inputId: Int? = null
private var input: Fragment? = null
lateinit var fragmentManager: FragmentManager
var listener: Listener? = null
fun showSoftkey(editText: EditText) {
ViewUtil.focusAndShowKeyboard(editText)
hideInput(resetKeyboardGuideline = false)
}
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, toggled: (Boolean) -> Unit = { }) {
if (fragmentCreator.id == inputId) {
hideInput(resetKeyboardGuideline = true)
toggled(false)
} else {
hideInput(resetKeyboardGuideline = false)
showInput(fragmentCreator, imeTarget)
}
}
fun hideInput() {
hideInput(resetKeyboardGuideline = true)
}
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {
inputId = fragmentCreator.id
input = fragmentCreator.create()
fragmentManager
.beginTransaction()
.replace(R.id.input_container, input!!)
.commit()
overrideKeyboardGuidelineWithPreviousHeight()
ViewUtil.hideKeyboard(context, imeTarget)
listener?.onInputShown()
}
private fun hideInput(resetKeyboardGuideline: Boolean) {
val inputHidden = input != null
input?.let {
fragmentManager
.beginTransaction()
.remove(it)
.commit()
}
input = null
inputId = null
if (resetKeyboardGuideline) {
resetKeyboardGuideline()
} else {
clearKeyboardGuidelineOverride()
}
if (inputHidden) {
listener?.onInputHidden()
}
}
interface FragmentCreator {
val id: Int
fun create(): Fragment
}
interface Listener {
fun onInputShown()
fun onInputHidden()
}
}

View File

@@ -17,6 +17,7 @@ import android.view.animation.AnimationSet;
import android.view.animation.Interpolator;
import android.view.animation.TranslateAnimation;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
@@ -30,6 +31,8 @@ import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftTable;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
@@ -52,9 +56,11 @@ import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository;
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -94,8 +100,9 @@ public class InputPanel extends LinearLayout
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
private View editMessageLabel;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageHeader;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -153,8 +160,9 @@ public class InputPanel extends LinearLayout
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction(false));
this.editMessageLabel = findViewById(R.id.edit_message);
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
@@ -385,22 +393,43 @@ public class InputPanel extends LinearLayout
quoteView.setWallpaperEnabled(enabled);
}
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage conversationMessageToEdit, boolean fromDraft) {
SpannableString textToEdit = conversationMessageToEdit.getDisplayBody(getContext());
if (!fromDraft) {
composeText.setText(textToEdit);
composeText.setSelection(textToEdit.length());
}
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
Quote quote = MessageRecordUtil.getQuote(conversationMessageToEdit.getMessageRecord());
if (quote == null) {
clearQuote();
} else {
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
}
this.messageToEdit = messageToEdit.getMessageRecord();
this.messageToEdit = conversationMessageToEdit.getMessageRecord();
updateEditModeThumbnail(glideRequests);
updateEditModeUi();
}
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
if (messageToEdit instanceof MediaMmsMessageRecord) {
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
editMessageThumbnail.setVisibility(VISIBLE);
glideRequests.load(new DecryptableStreamUriLoader.DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
.into(editMessageThumbnail);
} else {
editMessageThumbnail.setVisibility(View.GONE);
}
} else {
editMessageThumbnail.setVisibility(View.GONE);
}
}
public void exitEditMessageMode() {
if (messageToEdit != null) {
composeText.setText("");
@@ -413,13 +442,13 @@ public class InputPanel extends LinearLayout
private void updateEditModeUi() {
if (inEditMessageMode()) {
ViewUtil.focusAndShowKeyboard(composeText);
editMessageLabel.setVisibility(View.VISIBLE);
editMessageHeader.setVisibility(View.VISIBLE);
editMessageCancel.setVisibility(View.VISIBLE);
if (listener != null) {
listener.onEnterEditMode();
}
} else {
editMessageLabel.setVisibility(View.GONE);
editMessageHeader.setVisibility(View.GONE);
editMessageCancel.setVisibility(View.GONE);
if (listener != null) {
listener.onExitEditMode();

View File

@@ -1,89 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.util.AttributeSet;
import android.view.WindowInsets;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Guideline;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public class InsetAwareConstraintLayout extends ConstraintLayout {
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
private Insets insets;
public InsetAwareConstraintLayout(@NonNull Context context) {
super(context);
}
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
public InsetAwareConstraintLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
requestLayout();
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
applyInsets(newInsets);
return super.onApplyWindowInsets(insets);
}
public void applyInsets(@NonNull Insets insets) {
Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline);
Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline);
Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline);
Guideline parentEndGuideline = findViewById(R.id.parent_end_guideline);
if (statusBarGuideline != null) {
statusBarGuideline.setGuidelineBegin(insets.top);
}
if (navigationBarGuideline != null) {
navigationBarGuideline.setGuidelineEnd(insets.bottom);
}
if (parentStartGuideline != null) {
if (ViewUtil.isLtr(this)) {
parentStartGuideline.setGuidelineBegin(insets.left);
} else {
parentStartGuideline.setGuidelineBegin(insets.right);
}
}
if (parentEndGuideline != null) {
if (ViewUtil.isLtr(this)) {
parentEndGuideline.setGuidelineEnd(insets.right);
} else {
parentEndGuideline.setGuidelineEnd(insets.left);
}
}
}
public interface WindowInsetsTypeProvider {
WindowInsetsTypeProvider ALL = () ->
WindowInsetsCompat.Type.ime() |
WindowInsetsCompat.Type.systemBars() |
WindowInsetsCompat.Type.displayCutout();
@WindowInsetsCompat.Type.InsetsType
int getInsetsType();
}
}

View File

@@ -0,0 +1,219 @@
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Build
import android.util.AttributeSet
import android.util.DisplayMetrics
import android.view.Surface
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.Guideline
import androidx.core.content.withStyledAttributes
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
/**
* A specialized [ConstraintLayout] that sets guidelines based on the window insets provided
* by the system. For improved backwards-compatibility we must use [ViewCompat] for configuring
* the inset change callbacks.
*
* In portrait mode these are how the guidelines will be configured:
*
* - [R.id.status_bar_guideline] is set to the bottom of the status bar
* - [R.id.navigation_bar_guideline] is set to the top of the navigation bar
* - [R.id.parent_start_guideline] is set to the start of the parent
* - [R.id.parent_end_guideline] is set to the end of the parent
* - [R.id.keyboard_guideline] will be set to the top of the keyboard and will
* change as the keyboard is shown or hidden
*
* In landscape, the spirit of the guidelines are maintained but their names may not
* correlated exactly to the inset they are providing.
*
* These guidelines will only be updated if present in your layout, you can use
* `<include layout="@layout/system_ui_guidelines" />` to quickly include them.
*/
@Suppress("LeakingThis")
open class InsetAwareConstraintLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
companion object {
private val keyboardType = WindowInsetsCompat.Type.ime()
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
}
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
init {
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat ->
applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType))
windowInsetsCompat
}
if (attrs != null) {
context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) {
if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) {
ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator)
}
}
}
}
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(keyboardInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = keyboardInsets.bottom
}
} else if (!overridingKeyboard) {
if (!keyboardAnimator.animating) {
keyboardGuideline?.setGuidelineEnd(windowInsets.bottom)
} else {
keyboardAnimator.endingGuidelineEnd = windowInsets.bottom
}
}
}
protected fun overrideKeyboardGuidelineWithPreviousHeight() {
overridingKeyboard = true
keyboardGuideline?.setGuidelineEnd(getKeyboardHeight())
}
protected fun clearKeyboardGuidelineOverride() {
overridingKeyboard = false
}
protected fun resetKeyboardGuideline() {
clearKeyboardGuidelineOverride()
keyboardGuideline?.setGuidelineEnd(navigationBarGuideline.guidelineEnd)
}
private fun getKeyboardHeight(): Int {
val height = if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight
} else {
SignalStore.misc().keyboardPortraitHeight
}
return if (height <= 0) {
resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size)
} else {
height
}
}
private fun setKeyboardHeight(height: Int) {
if (isLandscape()) {
SignalStore.misc().keyboardLandscapeHeight = height
} else {
SignalStore.misc().keyboardPortraitHeight = height
}
}
private fun isLandscape(): Boolean {
val rotation = getDeviceRotation()
return rotation == Surface.ROTATION_90
}
@Suppress("DEPRECATION")
private fun getDeviceRotation(): Int {
if (isInEditMode) {
return Surface.ROTATION_0
}
if (Build.VERSION.SDK_INT >= 30) {
context.display?.getRealMetrics(displayMetrics)
} else {
ServiceUtil.getWindowManager(context).defaultDisplay.getRealMetrics(displayMetrics)
}
return if (displayMetrics.widthPixels > displayMetrics.heightPixels) Surface.ROTATION_90 else Surface.ROTATION_0
}
private val Guideline?.guidelineEnd: Int
get() = if (this == null) 0 else (layoutParams as LayoutParams).guideEnd
/**
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
*/
private inner class KeyboardInsetAnimator : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) {
var animating = false
private set
private var startingGuidelineEnd: Int = 0
var endingGuidelineEnd: Int = 0
set(value) {
field = value
growing = value > startingGuidelineEnd
}
private var growing: Boolean = false
override fun onPrepare(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
animating = true
startingGuidelineEnd = keyboardGuideline.guidelineEnd
}
override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList<WindowInsetsAnimationCompat>): WindowInsetsCompat {
if (overridingKeyboard) {
return insets
}
val imeAnimation = runningAnimations.find { it.typeMask and WindowInsetsCompat.Type.ime() != 0 }
if (imeAnimation == null) {
return insets
}
val estimatedKeyboardHeight: Int = if (growing) {
endingGuidelineEnd * imeAnimation.interpolatedFraction
} else {
startingGuidelineEnd * (1f - imeAnimation.interpolatedFraction)
}.toInt()
if (growing) {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(startingGuidelineEnd))
} else {
keyboardGuideline?.setGuidelineEnd(estimatedKeyboardHeight.coerceAtLeast(endingGuidelineEnd))
}
return insets
}
override fun onEnd(animation: WindowInsetsAnimationCompat) {
if (overridingKeyboard) {
return
}
keyboardGuideline?.setGuidelineEnd(endingGuidelineEnd)
animating = false
}
}
}

View File

@@ -5,7 +5,6 @@ import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.Spannable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
@@ -31,7 +30,6 @@ import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.quotes.QuoteViewColorTheme;
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -166,7 +164,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
setMessageType(messageType);
bodyView.enableSpoilerFiltering();
dismissView.setOnClickListener(view -> setVisibility(GONE));
}

View File

@@ -216,7 +216,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
.show(items)
}
interface SendTypeChangedListener {
fun interface SendTypeChangedListener {
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.emoji;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Paint.FontMetricsInt;
import android.graphics.drawable.Drawable;
@@ -55,6 +56,10 @@ public class EmojiSpan extends AnimatingImageSpan {
@Override
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint) {
if (paint.getColor() == Color.TRANSPARENT) {
return;
}
int height = bottom - top;
int centeringMargin = (height - size) / 2;
int adjustedMargin = (int) (centeringMargin * SHIFT_FACTOR);

View File

@@ -68,11 +68,9 @@ public class EmojiTextView extends AppCompatTextView {
private TextDirectionHeuristic textDirection;
private boolean isJumbomoji;
private boolean forceJumboEmoji;
private boolean isInOnDraw;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
private SpoilerFilteringSpannableFactory spoilerFilteringSpannableFactory;
private MentionRendererDelegate mentionRendererDelegate;
private final SpoilerRendererDelegate spoilerRendererDelegate;
public EmojiTextView(Context context) {
this(context, null);
@@ -113,15 +111,8 @@ public class EmojiTextView extends AppCompatTextView {
setText(getText());
}
public void enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = new SpoilerFilteringSpannableFactory(() -> isInOnDraw);
setSpannableFactory(spoilerFilteringSpannableFactory);
}
@Override
protected void onDraw(Canvas canvas) {
isInOnDraw = true;
boolean hasSpannedText = getText() instanceof Spanned;
boolean hasLayout = getLayout() != null;
@@ -134,8 +125,6 @@ public class EmojiTextView extends AppCompatTextView {
if (hasSpannedText && !hasLayout && getLayout() != null) {
drawSpecialRenderers(canvas, null, spoilerRendererDelegate);
}
isInOnDraw = false;
}
private void drawSpecialRenderers(@NonNull Canvas canvas, @Nullable MentionRendererDelegate mentionDelegate, @NonNull SpoilerRendererDelegate spoilerDelegate) {
@@ -187,9 +176,6 @@ public class EmojiTextView extends AppCompatTextView {
textToSet = new SpannableStringBuilder(EmojiProvider.emojify(candidates, text, this, isJumbomoji || forceJumboEmoji));
}
if (spoilerFilteringSpannableFactory != null) {
textToSet = spoilerFilteringSpannableFactory.wrap(textToSet);
}
super.setText(textToSet, BufferType.SPANNABLE);
// Android fails to ellipsize spannable strings. (https://issuetracker.google.com/issues/36991688)
@@ -333,11 +319,7 @@ public class EmojiTextView extends AppCompatTextView {
newTextToSet = EmojiProvider.emojify(newCandidates, newContent, this, isJumbomoji || forceJumboEmoji);
}
if (spoilerFilteringSpannableFactory != null) {
spoilerFilteringSpannableFactory.wrap(newTextToSet);
}
super.setText(newContent, BufferType.SPANNABLE);
super.setText(newTextToSet, BufferType.SPANNABLE);
}
}
@@ -366,7 +348,9 @@ public class EmojiTextView extends AppCompatTextView {
newContent.append(getText().subSequence(0, overflowStart).toString())
.append(ellipsized.subSequence(0, ellipsized.length()).toString());
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
if (newContent.length() > 0) {
TextUtils.copySpansFrom(getText(newContent.length() - 1), 0, newContent.length() - 1, Object.class, newContent, 0);
}
newContent.append(Optional.ofNullable(overflowText).orElse(""));

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.emoji
import android.content.Context
import android.graphics.Canvas
import android.text.Spannable
import android.text.Spanned
import android.text.TextUtils
import android.util.AttributeSet
@@ -21,8 +20,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
private var bufferType: BufferType? = null
private val sizeChangeDebouncer: ThrottledDebouncer = ThrottledDebouncer(200)
private val spoilerRendererDelegate: SpoilerRendererDelegate
private var spoilerFilteringSpannableFactory: SpoilerFilteringSpannableFactory? = null
private var isInOnDraw: Boolean = false
init {
isEmojiCompatEnabled = isInEditMode || SignalStore.settings().isPreferSystemEmoji
@@ -30,8 +27,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
override fun onDraw(canvas: Canvas) {
isInOnDraw = true
if (text is Spanned && layout != null) {
val checkpoint = canvas.save()
canvas.translate(totalPaddingLeft.toFloat(), totalPaddingTop.toFloat())
@@ -41,9 +36,8 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
canvas.restoreToCount(checkpoint)
}
}
super.onDraw(canvas)
isInOnDraw = false
super.onDraw(canvas)
}
override fun setText(text: CharSequence?, type: BufferType?) {
@@ -69,10 +63,6 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
TextUtils.ellipsize(newText, paint, (adjustedWidth * maxLines).toFloat(), TextUtils.TruncateAt.END, false, null)
}
if (newContent is Spannable && spoilerFilteringSpannableFactory != null) {
newContent = spoilerFilteringSpannableFactory!!.wrap(newContent)
}
bufferType = BufferType.SPANNABLE
super.setText(newContent, type)
}
@@ -86,9 +76,4 @@ open class SimpleEmojiTextView @JvmOverloads constructor(
}
}
}
fun enableSpoilerFiltering() {
spoilerFilteringSpannableFactory = SpoilerFilteringSpannableFactory { isInOnDraw }
setSpannableFactory(spoilerFilteringSpannableFactory!!)
}
}

View File

@@ -1,21 +0,0 @@
package org.thoughtcrime.securesms.components.emoji
import android.text.Spannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable
import org.thoughtcrime.securesms.util.SpoilerFilteringSpannable.InOnDrawProvider
/**
* Spannable factory used to help ensure spans are copied/maintained properly through the
* Android text handling system.
*
* @param inOnDraw Used by [SpoilerFilteringSpannable] to remove spans when being called from onDraw
*/
class SpoilerFilteringSpannableFactory(private val inOnDraw: InOnDrawProvider) : Spannable.Factory() {
override fun newSpannable(source: CharSequence): Spannable {
return wrap(super.newSpannable(source))
}
fun wrap(source: Spannable): SpoilerFilteringSpannable {
return SpoilerFilteringSpannable(source, inOnDraw)
}
}

View File

@@ -212,22 +212,21 @@ class SwitchPreferenceViewHolder(itemView: View) : PreferenceViewHolder<SwitchPr
switchWidget.setOnCheckedChangeListener(null)
switchWidget.isChecked = model.isChecked
switchWidget.isEnabled = model.isEnabled
switchWidget.setOnCheckedChangeListener { _, _ ->
model.onClick()
}
itemView.setOnClickListener {
model.onClick()
}
if (payload.contains(SwitchPreference.PAYLOAD_CHECKED)) {
return
}
super.bind(model)
switchWidget.isEnabled = model.isEnabled
itemView.setOnClickListener {
model.onClick()
}
}
}

View File

@@ -13,6 +13,7 @@ import org.greenrobot.eventbus.ThreadMode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder
import org.thoughtcrime.securesms.components.reminder.Reminder
import org.thoughtcrime.securesms.components.reminder.ReminderView
@@ -321,10 +322,14 @@ class AppSettingsFragment : DSLSettingsFragment(
private class BioPreferenceViewHolder(itemView: View) : PreferenceViewHolder<BioPreference>(itemView) {
private val avatarView: AvatarImageView = itemView.findViewById(R.id.icon)
private val aboutView: TextView = itemView.findViewById(R.id.about)
private val aboutView: EmojiTextView = itemView.findViewById(R.id.about)
private val badgeView: BadgeImageView = itemView.findViewById(R.id.badge)
private val qrButton: View = itemView.findViewById(R.id.qr_button)
init {
aboutView.setOverflowText(" ")
}
override fun bind(model: BioPreference) {
super.bind(model)

View File

@@ -28,7 +28,6 @@ import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.PinHashing
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity
import org.thoughtcrime.securesms.lock.v2.KbsConstants
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
@@ -39,6 +38,7 @@ import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFragment__account) {
@@ -247,7 +247,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag
pinEditText.typeface = Typeface.DEFAULT
turnOffButton.setOnClickListener {
val pin = pinEditText.text.toString()
val correct = PinHashing.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
val correct = PinHashUtil.verifyLocalPinHash(SignalStore.kbsValues().localPinHash!!, pin)
if (correct) {
SignalStore.pinValues().setPinRemindersEnabled(false)
viewModel.refreshState()

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.appearance
import android.os.Build
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.signal.core.util.concurrent.observe
@@ -67,6 +68,15 @@ class AppearanceSettingsFragment : DSLSettingsFragment(R.string.preferences__app
}
)
if (Build.VERSION.SDK_INT >= 26) {
clickPref(
title = DSLSettingsText.from(R.string.preferences__app_icon),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_appearanceSettings_to_appIconActivity)
}
)
}
radioListPref(
title = DSLSettingsText.from(R.string.preferences_chats__message_text_size),
listItems = messageFontSizeLabels,

View File

@@ -0,0 +1,325 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import android.content.Context
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconPreset
import org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util.AppIconUtility
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class AppIconSelectionFragment : ComposeFragment() {
private lateinit var appIconUtility: AppIconUtility
override fun onAttach(context: Context) {
super.onAttach(context)
appIconUtility = AppIconUtility(context)
}
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = stringResource(id = R.string.preferences__app_icon),
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
IconSelectionScreen(appIconUtility.currentAppIcon, ::updateAppIcon, ::openLearnMore, Modifier.padding(contentPadding))
}
}
private fun updateAppIcon(preset: AppIconPreset) {
if (!appIconUtility.isCurrentlySelected(preset)) {
appIconUtility.setNewAppIcon(preset)
}
}
private fun openLearnMore() {
findNavController().safeNavigate(R.id.action_appIconSelectionFragment_to_appIconTutorialFragment)
}
companion object {
val TAG = Log.tag(AppIconSelectionFragment::class.java)
}
}
private const val LEARN_MORE_TAG = "learn_more"
private const val URL_TAG = "URL"
private const val COLUMN_COUNT = 4
/**
* Screen allowing the user to view all the possible icon and select a new one to use.
*/
@Composable
fun IconSelectionScreen(activeIcon: AppIconPreset, onItemConfirmed: (AppIconPreset) -> Unit, onWarningClick: () -> Unit, modifier: Modifier = Modifier) {
var showDialog: Boolean by remember { mutableStateOf(false) }
var pendingIcon: AppIconPreset by remember {
mutableStateOf(activeIcon)
}
if (showDialog) {
ChangeIconDialog(
pendingIcon = pendingIcon,
onConfirm = {
onItemConfirmed(pendingIcon)
showDialog = false
},
onDismiss = {
pendingIcon = activeIcon
showDialog = false
}
)
}
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
Spacer(modifier = Modifier.size(12.dp))
CaveatWarning(
onClick = onWarningClick,
modifier = Modifier.padding(horizontal = 24.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
enumValues<AppIconPreset>().toList().chunked(COLUMN_COUNT).map { it.toImmutableList() }.forEach { items ->
IconRow(
presets = items,
isSelected = { it == pendingIcon },
onItemClick = {
pendingIcon = it
showDialog = true
}
)
}
}
}
}
@Composable
fun ChangeIconDialog(pendingIcon: AppIconPreset, onConfirm: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier) {
AlertDialog(
modifier = modifier,
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = onConfirm
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_ok))
}
},
dismissButton = {
TextButton(
onClick = onDismiss
) {
Text(text = stringResource(id = R.string.preferences__app_icon_dialog_cancel))
}
},
icon = {
AppIcon(preset = pendingIcon, isSelected = false, onClick = {})
},
title = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_title, stringResource(id = pendingIcon.labelResId)),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Text(
text = stringResource(id = R.string.preferences__app_icon_dialog_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium
)
}
)
}
/**
* Composable rendering the one row of icons that the user may choose from.
*/
@Composable
fun IconRow(presets: ImmutableList<AppIconPreset>, isSelected: (AppIconPreset) -> Boolean, onItemClick: (AppIconPreset) -> Unit, modifier: Modifier = Modifier) {
Row(modifier = modifier.fillMaxWidth()) {
presets.forEach { preset ->
val currentlySelected = isSelected(preset)
IconGridElement(
preset = preset,
isSelected = currentlySelected,
onClickHandler = {
if (!currentlySelected) {
onItemClick(preset)
}
},
modifier = Modifier
.padding(vertical = 18.dp)
.weight(1f)
)
}
}
}
/**
* Composable rendering an individual icon inside that grid, including the black border of the selected icon.
*/
@Composable
fun IconGridElement(preset: AppIconPreset, isSelected: Boolean, onClickHandler: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
val boxModifier = Modifier.size(64.dp)
Box(
modifier = if (isSelected) boxModifier.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) else boxModifier
) {
AppIcon(preset = preset, isSelected = isSelected, onClickHandler, modifier = Modifier.align(Alignment.Center))
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = stringResource(id = preset.labelResId),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Composable rendering the icon and optionally a border, to indicate selection.
*/
@Composable
fun AppIcon(preset: AppIconPreset, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val bitmapSize by animateFloatAsState(
targetValue = if (isSelected) 48f else 64f,
label = "Icon Size",
animationSpec = tween(durationMillis = 150, easing = CubicBezierEasing(0.17f, 0.17f, 0f, 1f))
)
val imageModifier = modifier
.size(bitmapSize.dp)
.graphicsLayer(
shape = CircleShape,
shadowElevation = if (isSelected) 4f else 8f,
clip = true
)
.clickable(onClick = onClick)
Image(
painterResource(id = preset.iconPreviewResId),
contentDescription = stringResource(id = preset.labelResId),
modifier = imageModifier
)
}
/**
* A clickable "learn more" block of text.
*/
@Composable
fun CaveatWarning(onClick: () -> Unit, modifier: Modifier = Modifier) {
val learnMoreString = stringResource(R.string.preferences__app_icon_learn_more)
val completeString = stringResource(R.string.preferences__app_icon_warning_learn_more)
val learnMoreStartIndex = completeString.indexOf(learnMoreString).coerceAtLeast(0)
val learnMoreEndIndex = learnMoreStartIndex + learnMoreString.length
val doesStringEndWithLearnMore = learnMoreEndIndex >= completeString.lastIndex
val annotatedText = buildAnnotatedString {
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
) {
append(completeString.substring(0, learnMoreStartIndex))
}
pushStringAnnotation(
tag = URL_TAG,
annotation = LEARN_MORE_TAG
)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary
)
) {
append(learnMoreString)
}
pop()
if (!doesStringEndWithLearnMore) {
append(completeString.substring(learnMoreEndIndex, completeString.lastIndex))
}
}
ClickableText(
text = annotatedText,
onClick = { onClick() },
style = MaterialTheme.typography.bodyMedium,
modifier = modifier
)
}
@Preview(name = "Light Theme")
@Composable
private fun MainScreenPreviewLight() {
SignalTheme(isDarkMode = false) {
Surface {
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
}
}
}
@Preview(name = "Dark Theme")
@Composable
private fun MainScreenPreviewDark() {
SignalTheme(isDarkMode = true) {
Surface {
IconSelectionScreen(AppIconPreset.DEFAULT, onItemConfirmed = {}, onWarningClick = {})
}
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class AppIconTutorialFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
Scaffolds.Settings(
title = "",
onNavigationClick = {
findNavController().popBackStack()
},
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { contentPadding: PaddingValues ->
TutorialScreen(Modifier.padding(contentPadding))
}
}
@Composable
fun TutorialScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier) {
Column(
modifier = Modifier
.padding(horizontal = 24.dp)
.align(Alignment.Center)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
val borderShape = RoundedCornerShape(12.dp)
Text(
text = stringResource(R.string.preferences__app_icon_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(vertical = 20.dp)
.fillMaxWidth()
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_apps_homescreen),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
Text(
text = stringResource(id = R.string.preferences__app_icon_notification_warning),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Start,
modifier = Modifier
.padding(vertical = 20.dp)
.fillMaxWidth()
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.clip(borderShape)
.border(1.dp, MaterialTheme.colorScheme.outline, shape = borderShape)
) {
Image(
painter = painterResource(R.drawable.app_icon_tutorial_notification),
contentDescription = stringResource(R.string.preferences__graphic_illustrating_where_the_replacement_app_icon_will_be_visible),
contentScale = ContentScale.FillWidth,
modifier = Modifier
.widthIn(max = 328.dp)
)
}
}
}
}
@Preview
@Composable
private fun TutorialScreenPreview() {
TutorialScreen()
}
companion object {
val TAG = Log.tag(AppIconTutorialFragment::class.java)
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
enum class AppIconPreset(private val componentName: String, @DrawableRes val iconPreviewResId: Int, @StringRes val labelResId: Int) {
DEFAULT(".RoutingActivity", R.drawable.ic_app_icon_default_top_preview, R.string.app_name),
WHITE(".RoutingActivityAltWhite", R.drawable.ic_app_icon_signal_white_top_preview, R.string.app_name),
COLOR(".RoutingActivityAltColor", R.drawable.ic_app_icon_signal_color_top_preview, R.string.app_name),
DARK(".RoutingActivityAltDark", R.drawable.ic_app_icon_signal_dark_top_preview, R.string.app_name),
DARK_VARIANT(".RoutingActivityAltDarkVariant", R.drawable.ic_app_icon_signal_dark_variant_top_preview, R.string.app_name),
CHAT(".RoutingActivityAltChat", R.drawable.ic_app_icon_chat_top_preview, R.string.app_name),
BUBBLES(".RoutingActivityAltBubbles", R.drawable.ic_app_icon_bubbles_top_preview, R.string.app_name),
YELLOW(".RoutingActivityAltYellow", R.drawable.ic_app_icon_yellow_top_preview, R.string.app_name),
NEWS(".RoutingActivityAltNews", R.drawable.ic_app_icon_news_top_preview, R.string.app_icon_label_news),
NOTES(".RoutingActivityAltNotes", R.drawable.ic_app_icon_notes_top_preview, R.string.app_icon_label_notes),
WEATHER(".RoutingActivityAltWeather", R.drawable.ic_app_icon_weather_top_preview, R.string.app_icon_label_weather),
WAVES(".RoutingActivityAltWaves", R.drawable.ic_app_icon_waves_top_preview, R.string.app_icon_label_waves);
fun getComponentName(context: Context): ComponentName {
val applicationContext = context.applicationContext
return ComponentName(applicationContext, "org.thoughtcrime.securesms" + componentName)
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.appearance.appicon.util
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import org.signal.core.util.logging.Log
class AppIconUtility(context: Context) {
private val applicationContext: Context = context.applicationContext
private val pm = applicationContext.packageManager
val currentAppIcon by lazy { readCurrentAppIconFromPackageManager() }
fun isCurrentlySelected(preset: AppIconPreset): Boolean {
return preset == currentAppIcon
}
fun currentAppIconComponentName(): ComponentName {
return currentAppIcon.getComponentName(applicationContext)
}
fun setNewAppIcon(desiredAppIcon: AppIconPreset) {
Log.d(TAG, "Setting new app icon.")
pm.setComponentEnabledSetting(desiredAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${desiredAppIcon.name} enabled.")
val previousAppIcon = currentAppIcon
pm.setComponentEnabledSetting(previousAppIcon.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${previousAppIcon.name} disabled.")
enumValues<AppIconPreset>().filterNot { it == desiredAppIcon || it == previousAppIcon }.forEach {
pm.setComponentEnabledSetting(it.getComponentName(applicationContext), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
Log.d(TAG, "${it.name} disabled.")
}
}
private fun readCurrentAppIconFromPackageManager(): AppIconPreset {
val activeIcon = enumValues<AppIconPreset>().firstOrNull {
val componentName = it.getComponentName(applicationContext)
val componentEnabledSetting = pm.getComponentEnabledSetting(componentName)
Log.d(TAG, "Found $componentName with state of $componentEnabledSetting")
if (it == AppIconPreset.DEFAULT && componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT) {
return it
}
componentEnabledSetting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
return if (activeIcon == null) {
setNewAppIcon(AppIconPreset.DEFAULT)
AppIconPreset.DEFAULT
} else {
activeIcon
}
}
companion object {
private const val TAG = "AppIconUtility"
}
}

View File

@@ -13,7 +13,6 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ChooseNavigationBarStyleFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
/**
* Allows the user to choose between a compact and full-sized navigation bar.
@@ -75,11 +74,7 @@ class ChooseNavigationBarStyleFragment : DialogFragment(R.layout.choose_navigati
companion object {
@DrawableRes
fun getImageResourceId(isCompact: Boolean): Int {
return if (FeatureFlags.callsTab()) {
ThreeButtons.getImageResource(isCompact)
} else {
TwoButtons.getImageResource(isCompact)
}
return ThreeButtons.getImageResource(isCompact)
}
}
}

View File

@@ -8,12 +8,12 @@ import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberUtil.changeNumberSuccess
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.PinHashing
import org.thoughtcrime.securesms.registration.fragments.BaseRegistrationLockFragment
import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.kbs.PinHashUtil
class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layout.fragment_change_number_registration_lock) {
@@ -42,7 +42,7 @@ class ChangeNumberRegistrationLockFragment : BaseRegistrationLockFragment(R.layo
}
override fun handleSuccessfulPinEntry(pin: String) {
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashing.verifyLocalPinHash(it, pin) } ?: false
val pinsDiffer: Boolean = SignalStore.kbsValues().localPinHash?.let { !PinHashUtil.verifyLocalPinHash(it, pin) } ?: false
pinButton.cancelSpinning()

View File

@@ -44,6 +44,13 @@ class HelpSettingsFragment : DSLSettingsFragment(R.string.preferences__help) {
}
)
clickPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__licenses),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_helpSettingsFragment_to_licenseFragment)
}
)
externalLinkPref(
title = DSLSettingsText.from(R.string.HelpSettingsFragment__terms_amp_privacy_policy),
linkId = R.string.terms_and_privacy_policy_url

View File

@@ -0,0 +1,72 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.help
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.ui.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
class LicenseFragment : ComposeFragment() {
private val TAG = Log.tag(LicenseFragment::class.java)
@Composable
override fun FragmentContent() {
val textState: State<String> = Single.fromCallable {
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeAsState(initial = "")
Scaffolds.Settings(
title = stringResource(id = R.string.HelpSettingsFragment__licenses),
onNavigationClick = findNavController()::popBackStack,
navigationIconPainter = painterResource(id = R.drawable.ic_arrow_left_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) {
LicenseScreen(licenseText = textState.value, modifier = Modifier.padding(it))
}
}
}
@Composable
fun LicenseScreen(licenseText: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = licenseText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 16.dp)
)
}
}
@Preview
@Composable
fun LicenseFragmentPreview() {
LicenseScreen("Lorem ipsum")
}

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
@@ -93,12 +94,12 @@ private fun DrawScope.drawQr(
}
// Logo border
val deadzonePaddingPercent = 0.02f
val deadzonePaddingPercent = 0.03f
val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2
drawCircle(
color = foregroundColor,
radius = logoBorderRadiusPx,
style = Stroke(width = cellWidthPx * 0.7f),
style = Stroke(width = 4.dp.toPx()),
center = this.center
)
@@ -156,9 +157,7 @@ private fun Preview() {
Surface {
QrCode(
data = QrCodeData.forData("https://signal.org", 64),
modifier = Modifier
.width(100.dp)
.height(100.dp),
modifier = Modifier.size(200.dp),
deadzonePercent = 0.3f
)
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Result of taking data from the QR scanner and trying to resolve it to a recipient.
*/
sealed class QrScanResult {
class Success(val recipient: Recipient) : QrScanResult()
class NotFound(val username: String) : QrScanResult()
object InvalidData : QrScanResult()
object NetworkError : QrScanResult()
}

View File

@@ -1,25 +1,61 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.CoroutineScope
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ComposeFragment
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class
)
class UsernameLinkSettingsFragment : ComposeFragment() {
val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val disposables: LifecycleDisposable = LifecycleDisposable()
@Composable
override fun FragmentContent() {
@@ -29,23 +65,121 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val navController: NavController by remember { mutableStateOf(findNavController()) }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { TopAppBarContent(state.activeTab) }
) { contentPadding ->
UsernameLinkShareScreen(
state = state,
snackbarHostState = snackbarHostState,
scope = scope,
contentPadding = contentPadding,
navController = navController
)
if (state.indeterminateProgress) {
Dialogs.IndeterminateProgressDialog()
}
AnimatedVisibility(
visible = state.activeTab == ActiveTab.Code,
enter = slideInHorizontally(initialOffsetX = { fullWidth -> -fullWidth }),
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> -fullWidth })
) {
UsernameLinkShareScreen(
state = state,
snackbarHostState = snackbarHostState,
scope = scope,
modifier = Modifier.padding(contentPadding),
navController = navController
)
}
AnimatedVisibility(
visible = state.activeTab == ActiveTab.Scan,
enter = slideInHorizontally(initialOffsetX = { fullWidth -> fullWidth }),
exit = slideOutHorizontally(targetOffsetX = { fullWidth -> fullWidth })
) {
UsernameQrScanScreen(
lifecycleOwner = viewLifecycleOwner,
disposables = disposables.disposables,
qrScanResult = state.qrScanResult,
onQrCodeScanned = { data -> viewModel.onQrCodeScanned(data) },
onQrResultHandled = { viewModel.onQrResultHandled() },
modifier = Modifier.padding(contentPadding)
)
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
}
override fun onResume() {
super.onResume()
viewModel.onResume()
}
@Composable
private fun TopAppBarContent(activeTab: ActiveTab) {
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
TabButton(
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
active = activeTab == ActiveTab.Code,
onClick = { viewModel.onTabSelected(ActiveTab.Code) },
modifier = Modifier.padding(end = 8.dp)
)
TabButton(
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
active = activeTab == ActiveTab.Scan,
onClick = {
if (cameraPermissionState.status.isGranted) {
viewModel.onTabSelected(ActiveTab.Scan)
} else {
cameraPermissionState.launchPermissionRequest()
}
}
)
}
}
}
@Composable
private fun TabButton(label: String, active: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val colors = if (active) {
ButtonDefaults.filledTonalButtonColors()
} else {
ButtonDefaults.buttonColors(
containerColor = SignalTheme.colors.colorSurface2,
contentColor = MaterialTheme.colorScheme.onSurface
)
}
Buttons.MediumTonal(
onClick = onClick,
modifier = modifier.defaultMinSize(minWidth = 100.dp),
shape = RoundedCornerShape(12.dp),
colors = colors
) {
Text(label)
}
}
@Preview
@Composable
private fun AppBarPreview() {
SignalTheme(isDarkMode = false) {
Surface {
TopAppBarContent(activeTab = ActiveTab.Code)
}
}
}
@Preview
@Composable
fun PreviewAll() {

View File

@@ -7,8 +7,15 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
* Represents the UI state of the [UsernameLinkSettingsFragment].
*/
data class UsernameLinkSettingsState(
val activeTab: ActiveTab,
val username: String,
val usernameLink: String,
val qrCodeData: QrCodeData?,
val qrCodeColorScheme: UsernameQrCodeColorScheme
)
val qrCodeColorScheme: UsernameQrCodeColorScheme,
val qrScanResult: QrScanResult? = null,
val indeterminateProgress: Boolean = false
) {
enum class ActiveTab {
Code, Scan
}
}

View File

@@ -9,17 +9,27 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.logging.Log
import org.signal.libsignal.usernames.BaseUsernameException
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import java.io.IOException
class UsernameLinkSettingsViewModel : ViewModel() {
private val TAG = Log.tag(UsernameLinkSettingsViewModel::class.java)
private val username: BehaviorSubject<String> = BehaviorSubject.createDefault(Recipient.self().username.get())
private val _state = mutableStateOf(
UsernameLinkSettingsState(
activeTab = ActiveTab.Code,
username = username.value!!,
usernameLink = UsernameUtil.generateLink(username.value!!),
qrCodeData = null,
@@ -54,6 +64,61 @@ class UsernameLinkSettingsViewModel : ViewModel() {
)
}
fun onTabSelected(tab: ActiveTab) {
_state.value = _state.value.copy(
activeTab = tab
)
}
fun onQrCodeScanned(url: String) {
_state.value = _state.value.copy(
indeterminateProgress = true
)
disposable += Single
.fromCallable {
val username: String? = UsernameUtil.parseLink(url)
if (username == null) {
Log.w(TAG, "Failed to parse username from url")
return@fromCallable QrScanResult.InvalidData
}
return@fromCallable try {
val hashed: String = UsernameUtil.hashUsernameToBase64(username)
val aci: ACI = ApplicationDependencies.getSignalServiceAccountManager().getAciByUsernameHash(hashed)
QrScanResult.Success(Recipient.externalUsername(aci, username))
} catch (e: BaseUsernameException) {
Log.w(TAG, "Invalid username", e)
QrScanResult.InvalidData
} catch (e: NonSuccessfulResponseCodeException) {
Log.w(TAG, "Non-successful response during username resolution", e)
if (e.code == 404) {
QrScanResult.NotFound(username)
} else {
QrScanResult.NetworkError
}
} catch (e: IOException) {
Log.w(TAG, "Network error during username resolution", e)
QrScanResult.NetworkError
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
_state.value = _state.value.copy(
qrScanResult = result,
indeterminateProgress = false
)
}
}
fun onQrResultHandled() {
_state.value = _state.value.copy(
qrScanResult = null
)
}
private fun generateQrCodeData(url: String): Single<QrCodeData> {
return Single.fromCallable {
QrCodeData.forData(url, 64)

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -36,6 +35,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeBadge
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.util.UsernameUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -49,12 +49,10 @@ fun UsernameLinkShareScreen(
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavController,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp)
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.padding(contentPadding)
.verticalScroll(rememberScrollState())
) {
QrCodeBadge(
@@ -85,7 +83,8 @@ fun UsernameLinkShareScreen(
text = stringResource(id = R.string.UsernameLinkSettings_qr_description),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp)
modifier = Modifier.padding(top = 24.dp, bottom = 36.dp, start = 43.dp, end = 43.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
@@ -186,6 +185,7 @@ private fun ScreenPreviewDarkTheme() {
private fun previewState(): UsernameLinkSettingsState {
val link = UsernameUtil.generateLink("maya.45")
return UsernameLinkSettingsState(
activeTab = ActiveTab.Code,
username = "maya.45",
usernameLink = link,
qrCodeData = QrCodeData.forData(link, 64),

View File

@@ -1,14 +1,144 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.signal.core.ui.Dialogs
import org.signal.qr.QrScannerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
import org.thoughtcrime.securesms.util.CommunicationActions
/**
* A screen that allows you to scan a QR code to start a chat.
*/
@Composable
fun UsernameQrScanScreen(modifier: Modifier = Modifier) {
// TODO
Text(text = "QR Scanner Placeholder")
fun UsernameQrScanScreen(
lifecycleOwner: LifecycleOwner,
disposables: CompositeDisposable,
qrScanResult: QrScanResult?,
onQrCodeScanned: (String) -> Unit,
onQrResultHandled: () -> Unit,
modifier: Modifier = Modifier
) {
when (qrScanResult) {
QrScanResult.InvalidData -> {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_invalid), onDismiss = onQrResultHandled)
}
QrScanResult.NetworkError -> {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_network_error), onDismiss = onQrResultHandled)
}
is QrScanResult.NotFound -> {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
}
is QrScanResult.Success -> {
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
onQrResultHandled()
}
null -> {}
}
Column(
modifier = modifier
.fillMaxWidth()
.fillMaxHeight()
) {
AndroidView(
factory = { context ->
val view = QrScannerView(context)
disposables += view.qrData.subscribe { data ->
onQrCodeScanned(data)
}
view
},
update = { view ->
view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted())
},
modifier = Modifier
.fillMaxWidth()
.weight(1f, true)
.drawWithContent {
drawContent()
drawQrCrosshair()
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.UsernameLinkSettings_qr_scan_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun QrScanResultDialog(message: String, onDismiss: () -> Unit) {
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(id = android.R.string.ok),
onDismiss = onDismiss
)
}
private fun DrawScope.drawQrCrosshair() {
val crosshairWidth: Float = size.minDimension * 0.6f
val clearWidth: Float = crosshairWidth * 0.75f
// Draw a full white rounded rect...
drawRoundRect(
color = Color.White,
topLeft = center - Offset(crosshairWidth / 2, crosshairWidth / 2),
style = Stroke(width = 3.dp.toPx()),
size = Size(crosshairWidth, crosshairWidth),
cornerRadius = CornerRadius(10.dp.toPx(), 10.dp.toPx())
)
// ...then cut out the middle parts with BlendMode.Clear to leave us with just the corners
drawRect(
color = Color.White,
topLeft = Offset(center.x - clearWidth / 2, 0f),
style = Fill,
size = Size(clearWidth, size.height),
blendMode = BlendMode.Clear
)
drawRect(
color = Color.White,
topLeft = Offset(0f, center.y - clearWidth / 2),
style = Fill,
size = Size(size.width, clearWidth),
blendMode = BlendMode.Clear
)
}

View File

@@ -4,10 +4,9 @@ import android.graphics.Color
import android.text.Annotation
import android.text.Selection
import android.text.Spannable
import android.text.SpannableString
import android.text.Spanned
import android.text.TextPaint
import android.text.style.ClickableSpan
import android.text.style.MetricAffectingSpan
import android.view.View
import android.widget.TextView
@@ -57,20 +56,18 @@ object SpoilerAnnotation {
revealedSpoilers.clear()
}
class SpoilerClickableSpan(private val spoiler: Annotation) : ClickableSpan() {
class SpoilerClickableSpan(private val spoiler: Annotation) : MetricAffectingSpan() {
val spoilerRevealed
get() = revealedSpoilers.contains(spoiler.value)
override fun onClick(widget: View) {
fun onClick(widget: View) {
revealedSpoilers.add(spoiler.value)
if (widget is TextView && Selection.getSelectionStart(widget.text) != -1) {
val text: Spannable = if (widget.text is Spannable) {
widget.text as Spannable
} else {
SpannableString(widget.text)
if (widget is TextView) {
val text = widget.text
if (text is Spannable) {
Selection.removeSelection(text)
}
Selection.removeSelection(text)
widget.text = text
}
}
@@ -79,5 +76,7 @@ object SpoilerAnnotation {
ds.color = Color.TRANSPARENT
}
}
override fun updateMeasureState(textPaint: TextPaint) = Unit
}
}

View File

@@ -1,109 +1,113 @@
package org.thoughtcrime.securesms.components.spoiler
import android.graphics.Canvas
import android.graphics.Paint
import android.text.Layout
import androidx.annotation.ColorInt
import androidx.annotation.Px
import org.thoughtcrime.securesms.util.LayoutUtil
/**
* Handles drawing the spoiler sparkles for a TextView.
*/
abstract class SpoilerRenderer {
class SpoilerRenderer(
private val spoilerDrawable: SpoilerDrawable,
private val renderForComposing: Boolean,
@Px private val padding: Int,
@ColorInt composeBackgroundColor: Int
) {
abstract fun draw(
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
private val paint = Paint().apply { color = composeBackgroundColor }
fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
)
protected fun getLineTop(layout: Layout, line: Int): Int {
return LayoutUtil.getLineTopWithoutPadding(layout, line)
}
protected fun getLineBottom(layout: Layout, line: Int): Int {
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
}
protected inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
return getOrPut(line * 31 + layout.hashCode() * 31, default)
}
class SingleLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
) {
if (startLine == endLine) {
val lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
val lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
val left = startOffset.coerceAtMost(endOffset)
val right = startOffset.coerceAtLeast(endOffset)
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
}
class MultiLineSpoilerRenderer(private val spoilerDrawable: SpoilerDrawable) : SpoilerRenderer() {
private val lineTopCache = HashMap<Int, Int>()
private val lineBottomCache = HashMap<Int, Int>()
override fun draw(
canvas: Canvas,
layout: Layout,
startLine: Int,
endLine: Int,
startOffset: Int,
endOffset: Int
) {
val paragraphDirection = layout.getParagraphDirection(startLine)
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
drawStart(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
for (line in startLine + 1 until endLine) {
val left: Int = layout.getLineLeft(line).toInt()
val right: Int = layout.getLineRight(line).toInt()
lineTop = getLineTop(layout, line)
lineBottom = getLineBottom(layout, line)
if (renderForComposing) {
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
} else {
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
drawEnd(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
return
}
private fun drawStart(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
if (start > end) {
spoilerDrawable.setBounds(end, top, start, bottom)
val paragraphDirection = layout.getParagraphDirection(startLine)
val lineEndOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineLeft(startLine) else layout.getLineRight(startLine)
var lineBottom = lineBottomCache.get(startLine, layout) { getLineBottom(layout, startLine) }
var lineTop = lineTopCache.get(startLine, layout) { getLineTop(layout, startLine) }
drawPartialLine(canvas, startOffset, lineTop, lineEndOffset.toInt(), lineBottom)
for (line in startLine + 1 until endLine) {
val left: Int = layout.getLineLeft(line).toInt()
val right: Int = layout.getLineRight(line).toInt()
lineTop = getLineTop(layout, line)
lineBottom = getLineBottom(layout, line)
if (renderForComposing) {
canvas.drawComposeBackground(left, lineTop, right, lineBottom)
} else {
spoilerDrawable.setBounds(start, top, end, bottom)
spoilerDrawable.setBounds(left, lineTop, right, lineBottom)
spoilerDrawable.draw(canvas)
}
spoilerDrawable.draw(canvas)
}
private fun drawEnd(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
if (start > end) {
spoilerDrawable.setBounds(end, top, start, bottom)
} else {
spoilerDrawable.setBounds(start, top, end, bottom)
}
spoilerDrawable.draw(canvas)
val lineStartOffset: Float = if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) layout.getLineRight(startLine) else layout.getLineLeft(startLine)
lineBottom = lineBottomCache.get(endLine, layout) { getLineBottom(layout, endLine) }
lineTop = lineTopCache.get(endLine, layout) { getLineTop(layout, endLine) }
drawPartialLine(canvas, lineStartOffset.toInt(), lineTop, endOffset, lineBottom)
}
private fun drawPartialLine(canvas: Canvas, start: Int, top: Int, end: Int, bottom: Int) {
if (renderForComposing) {
canvas.drawComposeBackground(start, top, end, bottom)
return
}
if (start > end) {
spoilerDrawable.setBounds(end, top, start, bottom)
} else {
spoilerDrawable.setBounds(start, top, end, bottom)
}
spoilerDrawable.draw(canvas)
}
private fun getLineTop(layout: Layout, line: Int): Int {
return LayoutUtil.getLineTopWithoutPadding(layout, line)
}
private fun getLineBottom(layout: Layout, line: Int): Int {
return LayoutUtil.getLineBottomWithoutPadding(layout, line)
}
private inline fun MutableMap<Int, Int>.get(line: Int, layout: Layout, default: () -> Int): Int {
return getOrPut(line * 31 + layout.hashCode() * 31, default)
}
private fun Canvas.drawComposeBackground(start: Int, top: Int, end: Int, bottom: Int) {
drawRoundRect(
start.toFloat() - padding,
top.toFloat() - padding,
end.toFloat() + padding,
bottom.toFloat(),
padding.toFloat(),
padding.toFloat(),
paint
)
}
}

View File

@@ -9,18 +9,17 @@ import android.view.View
import android.view.View.OnAttachStateChangeListener
import android.view.animation.LinearInterpolator
import android.widget.TextView
import androidx.core.content.ContextCompat
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation.SpoilerClickableSpan
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.MultiLineSpoilerRenderer
import org.thoughtcrime.securesms.components.spoiler.SpoilerRenderer.SingleLineSpoilerRenderer
/**
* Performs initial calculation on how to render spoilers and then delegates to the single line or
* multi-line version of actually drawing the spoiler sparkles.
* Performs initial calculation on how to render spoilers and then delegates to actually drawing the spoiler sparkles.
*/
class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextView, private val renderForComposing: Boolean = false) {
private val single: SpoilerRenderer
private val multi: SpoilerRenderer
private val renderer: SpoilerRenderer
private val spoilerDrawable: SpoilerDrawable
private var animatorRunning = false
private var textColor: Int
@@ -42,8 +41,12 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
init {
textColor = view.textColors.defaultColor
spoilerDrawable = SpoilerDrawable(textColor)
single = SingleLineSpoilerRenderer(spoilerDrawable)
multi = MultiLineSpoilerRenderer(spoilerDrawable)
renderer = SpoilerRenderer(
spoilerDrawable = spoilerDrawable,
renderForComposing = renderForComposing,
padding = 2.dp,
composeBackgroundColor = ContextCompat.getColor(view.context, R.color.signal_colorOnSurfaceVariant1)
)
view.addOnAttachStateChangeListener(object : OnAttachStateChangeListener {
override fun onViewDetachedFromWindow(v: View) = stopAnimating()
@@ -85,8 +88,6 @@ class SpoilerRendererDelegate @JvmOverloads constructor(private val view: TextVi
)
}
val renderer: SpoilerRenderer = if (measurements.startLine == measurements.endLine) single else multi
renderer.draw(canvas, layout, measurements.startLine, measurements.endLine, measurements.startOffset, measurements.endOffset)
hasSpoilersToRender = true
}

View File

@@ -277,7 +277,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
if (ongoing && !isForegroundService) {
try {
ForegroundServiceUtil.startWhenCapable(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
startForeground(notificationId, notification);
isForegroundService = true;
} catch (UnableToStartException e) {

View File

@@ -5,6 +5,8 @@ import androidx.annotation.CheckResult
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RotateProfileKeyJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
@@ -31,7 +33,11 @@ class ContactsManagementRepository(context: Context) {
error("Cannot hide groups, self, or distribution lists.")
}
SignalDatabase.recipients.markHidden(recipient.id)
val rotateProfileKey = !recipient.hasGroupsInCommon()
SignalDatabase.recipients.markHidden(recipient.id, rotateProfileKey)
if (rotateProfileKey) {
ApplicationDependencies.getJobManager().add(RotateProfileKeyJob())
}
}
}
}

View File

@@ -74,7 +74,7 @@ class ContactSearchPagedDataSource(
return searchSize
}
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
override fun load(start: Int, length: Int, totalSize: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<ContactSearchData> {
val sections: List<ContactSearchConfiguration.Section> = if (displayEmptyState) {
contactConfiguration.emptyStateSections
} else {

View File

@@ -37,7 +37,6 @@ import org.whispersystems.signalservice.api.push.ServiceId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
@@ -99,7 +98,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
}
@Override
public @NonNull List<ConversationMessage> load(int start, int length, @NonNull CancellationSignal cancellationSignal) {
public @NonNull List<ConversationMessage> load(int start, int length, int totalSize, @NonNull CancellationSignal cancellationSignal) {
Stopwatch stopwatch = new Stopwatch("load(" + start + ", " + length + "), thread " + threadId);
List<MessageRecord> records = new ArrayList<>(length);
MentionHelper mentionHelper = new MentionHelper();
@@ -128,11 +127,11 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
}
}
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= size())) {
if (messageRequestData.includeWarningUpdateMessage() && (start + length >= totalSize)) {
records.add(new InMemoryMessageRecord.NoGroupsInCommon(threadId, messageRequestData.isGroup()));
}
if (messageRequestData.isHidden() && (start + length >= size())) {
if (messageRequestData.isHidden() && (start + length >= totalSize)) {
records.add(new InMemoryMessageRecord.RemovedContactHidden(threadId));
}

View File

@@ -76,6 +76,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.badges.gifts.OpenableGift;
@@ -89,7 +90,6 @@ import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar;
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment;
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -194,7 +194,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.WindowUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import java.io.IOException;
@@ -320,7 +319,17 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
},
() -> conversationViewModel.shouldPlayMessageAnimations() && list.getScrollState() == RecyclerView.SCROLL_STATE_IDLE,
() -> list.canScrollVertically(1) || list.canScrollVertically(-1));
() -> list.canScrollVertically(1) || list.canScrollVertically(-1),
(viewHolder) -> {
if (viewHolder instanceof ConversationAdapter.ConversationViewHolder) {
ConversationAdapter.ConversationViewHolder conversationViewHolder = (ConversationAdapter.ConversationViewHolder) viewHolder;
BindableConversationItem conversationItem = conversationViewHolder.getBindable();
if (conversationItem != null) {
return !MessageRecordUtil.isEditMessage(conversationItem.getConversationMessage().getMessageRecord());
}
}
return true;
});
multiselectItemDecoration = new MultiselectItemDecoration(requireContext(), () -> chatWallpaper);
@@ -1191,7 +1200,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}
public void stageOutgoingMessage(OutgoingMessage message) {
if (message.getScheduledDate() != -1) {
if (message.getScheduledDate() != -1 || message.isMessageEdit()) {
return;
}
@@ -1838,8 +1847,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms) {
if (getParentFragment() == null) return;
final String REACTIONS_TAG = "REACTIONS";
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), null);
if (getParentFragmentManager().findFragmentByTag(REACTIONS_TAG) == null) {
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(getParentFragmentManager(), REACTIONS_TAG);
}
}
@Override
@@ -2027,6 +2039,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
@Override
public void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args) {
if (listener.isInBubble()) {
Intent intent = ConversationIntents.createBuilder(requireActivity(), recipient.getId(), threadId)
.withStartingPosition(list.getChildAdapterPosition(parent))
.build();
requireActivity().startActivity(intent);
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args.skipSharedElementTransition(true)));
return;
}
@@ -2045,26 +2062,23 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
requireActivity().setExitSharedElementCallback(new MaterialContainerTransformSharedElementCallback());
ActivityOptions options = ActivityOptions.makeSceneTransitionAnimation(requireActivity(), sharedElement, MediaPreviewV2Activity.SHARED_ELEMENT_TRANSITION_NAME);
final Intent mediaPreviewIntent = MediaIntentFactory.create(requireActivity(), args);
if (listener.isInBubble()) {
mediaPreviewIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_SINGLE_TOP);
}
requireActivity().startActivity(mediaPreviewIntent, options.toBundle());
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
}
@Override
public void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord.getId());
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord);
} else {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord.getId());
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord);
}
}
@Override
public void onShowGroupDescriptionClicked(@NonNull String groupName, @NonNull String description, boolean shouldLinkifyWebLinks) {
GroupDescriptionDialog.show(getChildFragmentManager(), groupName, description, shouldLinkifyWebLinks);
}
@Override
public void onActivatePaymentsClicked() {
Intent intent = new Intent(requireContext(), PaymentsActivity.class);

View File

@@ -411,6 +411,10 @@ public class ConversationIntents {
return Objects.equals(this, BUBBLE);
}
public boolean isInPopup() {
return Objects.equals(this, POPUP);
}
public boolean isNormal() {
return Objects.equals(this, NORMAL);
}
@@ -437,7 +441,7 @@ public class ConversationIntents {
}
private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) {
if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) {
if (threadId < 0 && SignalStore.internalValues().useConversationFragmentV2()) {
Log.w(TAG, "Getting thread id from database...");
// TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this.
return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId));

View File

@@ -91,7 +91,6 @@ import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
@@ -342,7 +341,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
bodyText.setOnLongClickListener(passthroughClickListener);
bodyText.setOnClickListener(passthroughClickListener);
bodyText.enableSpoilerFiltering();
footer.setOnTouchDelegateChangedListener(touchDelegateChangedListener);
}
@@ -488,6 +486,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return SWIPE_RECT.contains((int) downX, (int) downY);
}
public @Nullable ConversationItemBodyBubble getBodyBubble() {
return bodyBubble;
}
public @Nullable View getQuotedIndicator() {
return quotedIndicator;
}
public @Nullable ReactionsConversationView getReactionsView() {
return reactionsView;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
@@ -1316,7 +1326,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
paymentViewStub.setVisibility(View.VISIBLE);
paymentViewStub.get().bindPayment(messageRecord.getFromRecipient(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
paymentViewStub.get().bindPayment(conversationRecipient.get(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
footer.setVisibility(VISIBLE);
} else {
@@ -1514,6 +1524,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int end = messageBody.getSpanEnd(urlSpan);
URLSpan span = new InterceptableLongClickCopyLinkSpan(urlSpan.getURL(), urlClickHandler);
messageBody.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
messageBody.removeSpan(urlSpan);
}
}
}
@@ -1933,10 +1944,6 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
return false;
}
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||

View File

@@ -7,7 +7,7 @@ enum class ConversationItemDisplayMode {
/** Smaller bubbles, often trimming text and shrinking images. Used for quote threads. */
CONDENSED,
/** Smaller bubbles, no footers */
/** Smaller bubbles, always singular bubbles, with a footer. Used for edit message history. */
EXTRA_CONDENSED,
/** Less length restrictions. Used to show more info in message details. */

View File

@@ -2102,7 +2102,7 @@ public class ConversationParentFragment extends Fragment
protected void initializeActionBar() {
invalidateOptionsMenu();
toolbar.setOnMenuItemClickListener(menuProvider::onMenuItemSelected);
toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button);
if (isInBubble()) {
toolbar.setNavigationIcon(DrawableUtil.tint(ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification),
ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)));
@@ -2806,8 +2806,9 @@ public class ConversationParentFragment extends Fragment
fragment.reload(recipient.get(), threadId);
setVisibleThread(threadId);
}
fragment.scrollToBottom();
if (!inputPanel.inEditMessageMode()) {
fragment.scrollToBottom();
}
attachmentManager.cleanup();
updateLinkPreviewState();
@@ -3281,7 +3282,7 @@ public class ConversationParentFragment extends Fragment
@Override
public void onRecorderStarted() {
final AudioManagerCompat audioManager = ApplicationDependencies.getAndroidCallAudioManager();
if (audioManager.isBluetoothAvailable()) {
if (audioManager.isBluetoothHeadsetAvailable()) {
connectToBluetoothAndBeginRecording();
} else {
Log.d(TAG, "Recording from phone mic because no bluetooth devices were available.");
@@ -3334,11 +3335,16 @@ public class ConversationParentFragment extends Fragment
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
voiceRecorderWakeLock.release();
updateToggleButtonState();
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
vibrator.vibrate(20);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
Activity activity = getActivity();
if (activity != null) {
Vibrator vibrator = ServiceUtil.getVibrator(activity);
vibrator.vibrate(20);
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
}
if (recordingSession != null) {
recordingSession.completeRecording();
}
@@ -3346,13 +3352,18 @@ public class ConversationParentFragment extends Fragment
@Override
public void onRecorderCanceled(boolean byUser) {
bluetoothVoiceNoteUtil.disconnectBluetoothScoConnection();
voiceRecorderWakeLock.release();
updateToggleButtonState();
Vibrator vibrator = ServiceUtil.getVibrator(requireContext());
vibrator.vibrate(50);
requireActivity().getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
Activity activity = getActivity();
if (activity != null) {
Vibrator vibrator = ServiceUtil.getVibrator(activity);
vibrator.vibrate(50);
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
}
if (recordingSession != null) {
if (byUser) {
@@ -4275,24 +4286,10 @@ public class ConversationParentFragment extends Fragment
return;
}
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext())
.setNeutralButton(R.string.ConversationActivity_cancel, (d, w) -> d.dismiss());
if (recipient.isGroup() && recipient.isBlocked()) {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setPositiveButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
} else if (recipient.isGroup()) {
builder.setTitle(R.string.ConversationActivity_delete_and_leave_group);
builder.setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete_and_leave, (d, w) -> requestModel.onDelete());
} else {
builder.setTitle(R.string.ConversationActivity_delete_conversation);
builder.setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices);
builder.setNegativeButton(R.string.ConversationActivity_delete, (d, w) -> requestModel.onDelete());
}
builder.show();
ConversationDialogs.displayDeleteDialog(requireContext(), recipient, () -> {
requestModel.onDelete();
return Unit.INSTANCE;
});
}
private void onMessageRequestBlockClicked(@NonNull MessageRequestViewModel requestModel) {

View File

@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
* respecting listeners and other positional information that can be set BEFORE we want to actually
* resolve the view.
*/
final class ConversationReactionDelegate {
public final class ConversationReactionDelegate {
private final Stub<ConversationReactionOverlay> overlayStub;
private final PointF lastSeenDownPoint = new PointF();
@@ -26,15 +26,15 @@ final class ConversationReactionDelegate {
private ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener;
private ConversationReactionOverlay.OnHideListener onHideListener;
ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
public ConversationReactionDelegate(@NonNull Stub<ConversationReactionOverlay> overlayStub) {
this.overlayStub = overlayStub;
}
boolean isShowing() {
public boolean isShowing() {
return overlayStub.resolved() && overlayStub.get().isShowing();
}
void show(@NonNull Activity activity,
public void show(@NonNull Activity activity,
@NonNull Recipient conversationRecipient,
@NonNull ConversationMessage conversationMessage,
boolean isNonAdminInAnnouncementGroup,
@@ -43,15 +43,15 @@ final class ConversationReactionDelegate {
resolveOverlay().show(activity, conversationRecipient, conversationMessage, lastSeenDownPoint, isNonAdminInAnnouncementGroup, selectedConversationModel);
}
void hide() {
public void hide() {
overlayStub.get().hide();
}
void hideForReactWithAny() {
public void hideForReactWithAny() {
overlayStub.get().hideForReactWithAny();
}
void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
public void setOnReactionSelectedListener(@NonNull ConversationReactionOverlay.OnReactionSelectedListener onReactionSelectedListener) {
this.onReactionSelectedListener = onReactionSelectedListener;
if (overlayStub.resolved()) {
@@ -59,7 +59,7 @@ final class ConversationReactionDelegate {
}
}
void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
public void setOnActionSelectedListener(@NonNull ConversationReactionOverlay.OnActionSelectedListener onActionSelectedListener) {
this.onActionSelectedListener = onActionSelectedListener;
if (overlayStub.resolved()) {
@@ -67,7 +67,7 @@ final class ConversationReactionDelegate {
}
}
void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
public void setOnHideListener(@NonNull ConversationReactionOverlay.OnHideListener onHideListener) {
this.onHideListener = onHideListener;
if (overlayStub.resolved()) {
@@ -75,7 +75,7 @@ final class ConversationReactionDelegate {
}
}
@NonNull MessageRecord getMessageRecord() {
public @NonNull MessageRecord getMessageRecord() {
if (!overlayStub.resolved()) {
throw new IllegalStateException("Cannot call getMessageRecord right now.");
}
@@ -83,7 +83,7 @@ final class ConversationReactionDelegate {
return overlayStub.get().getMessageRecord();
}
boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
public boolean applyTouchEvent(@NonNull MotionEvent motionEvent) {
if (!overlayStub.resolved() || !overlayStub.get().isShowing()) {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
lastSeenDownPoint.set(motionEvent.getX(), motionEvent.getY());

View File

@@ -23,7 +23,7 @@ import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.constraintlayout.widget.Barrier;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.core.content.ContextCompat;
@@ -33,6 +33,7 @@ import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat;
import com.annimon.stream.Stream;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
@@ -408,6 +409,12 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
private int getInputPanelHeight(@NonNull Activity activity) {
if (SignalStore.internalValues().useConversationFragmentV2()) {
Barrier conversationBottomPanelBarrier = activity.findViewById(R.id.conversation_bottom_panel_barrier);
return activity.getResources().getDisplayMetrics().heightPixels - conversationBottomPanelBarrier.getTop();
}
View bottomPanel = activity.findViewById(R.id.conversation_activity_panel_parent);
View emojiDrawer = activity.findViewById(R.id.emoji_drawer);
@@ -428,7 +435,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
}
}
@RequiresApi(api = 21)
private void updateSystemUiOnShow(@NonNull Activity activity) {
Window window = activity.getWindow();
int barColor = ContextCompat.getColor(getContext(), R.color.conversation_item_selected_system_ui);

View File

@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
final class MenuState {
public final class MenuState {
private static final int MAX_FORWARDABLE_COUNT = 32;
@@ -41,50 +41,50 @@ final class MenuState {
edit = builder.edit;
}
boolean shouldShowForwardAction() {
public boolean shouldShowForwardAction() {
return forward;
}
boolean shouldShowReplyAction() {
public boolean shouldShowReplyAction() {
return reply;
}
boolean shouldShowDetailsAction() {
public boolean shouldShowDetailsAction() {
return details;
}
boolean shouldShowSaveAttachmentAction() {
public boolean shouldShowSaveAttachmentAction() {
return saveAttachment;
}
boolean shouldShowResendAction() {
public boolean shouldShowResendAction() {
return resend;
}
boolean shouldShowCopyAction() {
public boolean shouldShowCopyAction() {
return copy;
}
boolean shouldShowDeleteAction() {
public boolean shouldShowDeleteAction() {
return delete;
}
boolean shouldShowReactions() {
public boolean shouldShowReactions() {
return reactions;
}
boolean shouldShowPaymentDetails() {
public boolean shouldShowPaymentDetails() {
return paymentDetails;
}
boolean shouldShowEditAction() {
public boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
Builder builder = new Builder();

View File

@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.conversation.ConversationAdapter
class ConversationItemAnimator(
private val isInMultiSelectMode: () -> Boolean,
private val shouldPlayMessageAnimations: () -> Boolean,
private val isParentFilled: () -> Boolean
private val isParentFilled: () -> Boolean,
private val shouldUseSlideAnimation: (RecyclerView.ViewHolder) -> Boolean
) : RecyclerView.ItemAnimator() {
private data class TweeningInfo(
@@ -54,7 +55,7 @@ class ConversationItemAnimator(
}
override fun animateAppearance(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo?, postLayoutInfo: ItemHolderInfo): Boolean {
if (viewHolder.absoluteAdapterPosition > 1) {
if (viewHolder.absoluteAdapterPosition > 1 || !shouldUseSlideAnimation(viewHolder)) {
dispatchAnimationFinished(viewHolder)
return false
}
@@ -95,7 +96,7 @@ class ConversationItemAnimator(
override fun animatePersistence(viewHolder: RecyclerView.ViewHolder, preLayoutInfo: ItemHolderInfo, postLayoutInfo: ItemHolderInfo): Boolean {
return if (!isInMultiSelectMode() && shouldPlayMessageAnimations() && isParentFilled()) {
if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder)) {
if (pendingSlideAnimations.contains(viewHolder) || slideAnimations.containsKey(viewHolder) || !shouldUseSlideAnimation(viewHolder)) {
dispatchAnimationFinished(viewHolder)
false
} else {

View File

@@ -80,7 +80,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter
itemAnimator = null
addItemDecoration(MessageQuoteHeaderDecoration(context))
addItemDecoration(OriginalMessageSeparatorDecoration(context, R.string.MessageQuotesBottomSheet_replies))
doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view

View File

@@ -7,6 +7,7 @@ import android.graphics.Rect
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.core.view.children
import androidx.core.view.marginLeft
import androidx.recyclerview.widget.RecyclerView
@@ -14,9 +15,9 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.ViewUtil
/**
* Serves as the separator between the original message and the messages that quote it in [MessageQuotesBottomSheet]
* Serves as the separator between the original message and other messages. Used in [MessageQuotesBottomSheet] and [EditMessageHistoryDialog]
*/
class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecoration() {
class OriginalMessageSeparatorDecoration(context: Context, val titleRes: Int) : RecyclerView.ItemDecoration() {
private val dividerMargin = ViewUtil.dpToPx(context, 32)
private val dividerHeight = ViewUtil.dpToPx(context, 2)
@@ -56,7 +57,9 @@ class MessageQuoteHeaderDecoration(context: Context) : RecyclerView.ItemDecorati
return it
}
val header: View = LayoutInflater.from(parent.context).inflate(R.layout.message_quote_header_decoration, parent, false)
val header: View = LayoutInflater.from(parent.context).inflate(R.layout.original_message_separator_decoration, parent, false)
val titleView: TextView = header.findViewById(R.id.separator_title)
titleView.setText(titleRes)
val widthSpec = View.MeasureSpec.makeMeasureSpec(parent.width, View.MeasureSpec.EXACTLY)
val heightSpec = View.MeasureSpec.makeMeasureSpec(parent.height, View.MeasureSpec.UNSPECIFIED)

View File

@@ -10,6 +10,7 @@ import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.conversation.quotes.OriginalMessageSeparatorDecoration
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
@@ -45,9 +47,9 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.4f
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
private val originalMessageId: Long by lazy { requireArguments().getLong(ARGUMENT_ORIGINAL_MESSAGE_ID) }
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(originalMessageId, conversationRecipient) })
private val disposables: LifecycleDisposable = LifecycleDisposable()
@@ -76,9 +78,10 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
}
binding.editHistoryList.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, true)
adapter = messageAdapter
itemAnimator = null
addItemDecoration(OriginalMessageSeparatorDecoration(context, R.string.EditMessageHistoryDialog_title))
}
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
@@ -144,15 +147,15 @@ class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
}
companion object {
private const val ARGUMENT_MESSAGE_ID = "message_id"
private const val ARGUMENT_ORIGINAL_MESSAGE_ID = "message_id"
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageRecord: MessageRecord) {
EditMessageHistoryDialog()
.apply {
arguments = bundleOf(
ARGUMENT_MESSAGE_ID to messageId,
ARGUMENT_ORIGINAL_MESSAGE_ID to (messageRecord.originalMessageId?.id ?: messageRecord.id),
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
)
}

View File

@@ -36,16 +36,16 @@ object EditMessageHistoryRepository {
.getMessageEditHistory(messageId)
.toList()
if (records.isEmpty()) {
return emptyList()
}
val attachmentHelper = AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
if (records.isEmpty()) {
return emptyList()
}
val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(records[0].threadId))
return attachmentHelper

View File

@@ -13,12 +13,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model to show history of edits for a specific message.
*/
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
class EditMessageHistoryViewModel(private val originalMessageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
fun getEditHistory(): Observable<List<ConversationMessage>> {
return EditMessageHistoryRepository
.getEditHistory(messageId)
.getEditHistory(originalMessageId)
.observeOn(AndroidSchedulers.mainThread())
}

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2
import android.content.Intent
import android.view.MotionEvent
import androidx.activity.viewModels
import androidx.fragment.app.Fragment
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
@@ -15,6 +17,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
private val theme = DynamicNoActionBarTheme()
override val voiceNoteMediaController = VoiceNoteMediaController(this, true)
private val motionEventRelay: MotionEventRelay by viewModels()
override fun onPreCreate() {
theme.onCreate(this)
}
@@ -32,4 +36,8 @@ class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaController
super.onNewIntent(intent)
error("ON NEW INTENT")
}
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
}

View File

@@ -5,8 +5,10 @@
package org.thoughtcrime.securesms.conversation.v2
import android.text.TextUtils
import android.view.View
import android.view.ViewGroup
import androidx.core.text.HtmlCompat
import androidx.lifecycle.LifecycleOwner
import com.google.android.exoplayer2.MediaItem
import org.signal.core.util.logging.Log
@@ -15,6 +17,7 @@ import org.thoughtcrime.securesms.BindableConversationItem
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationAdapterBridge
import org.thoughtcrime.securesms.conversation.ConversationBannerView
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.Colorizable
@@ -27,11 +30,17 @@ import org.thoughtcrime.securesms.conversation.v2.data.IncomingMedia
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingMedia
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.ThreadHeader
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer
import org.thoughtcrime.securesms.groups.v2.GroupDescriptionUtil
import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.CachedInflater
import org.thoughtcrime.securesms.util.HtmlUtil
import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.ProjectionList
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@@ -65,6 +74,8 @@ class ConversationAdapterV2(
private val condensedMode: ConversationItemDisplayMode? = null
init {
registerFactory(ThreadHeader::class.java, ::ThreadHeaderViewHolder, R.layout.conversation_item_banner)
registerFactory(ConversationUpdate::class.java) { parent ->
val view = CachedInflater.from(parent.context).inflate<View>(R.layout.conversation_item_update, parent, false)
ConversationUpdateViewHolder(view)
@@ -91,14 +102,14 @@ class ConversationAdapterV2(
}
}
fun getAdapterPositionForMessagePosition(startPosition: Int): Int {
return startPosition - 1
/** [messagePosition] is one-based index and adapter is zero-based. */
fun getAdapterPositionForMessagePosition(messagePosition: Int): Int {
return messagePosition - 1
}
fun getLastVisibleConversationMessage(position: Int): ConversationMessage? {
return try {
// todo [cody] handle conversation banner adjustment
getConversationMessage(position)
getConversationMessage(position) ?: getConversationMessage(position - 1)
} catch (e: IndexOutOfBoundsException) {
Log.w(TAG, "Race condition changed size of conversation", e)
null
@@ -131,6 +142,7 @@ class ConversationAdapterV2(
override fun getConversationMessage(position: Int): ConversationMessage? {
return when (val item = getItem(position)) {
is ConversationMessageElement -> item.conversationMessage
is ThreadHeader -> null
null -> null
else -> throw AssertionError("Invalid item: ${item.javaClass}")
}
@@ -166,6 +178,18 @@ class ConversationAdapterV2(
// todo [cody] implement
}
fun clearSelection() {
_selected.clear()
}
fun toggleSelection(multiselectPart: MultiselectPart) {
if (multiselectPart in _selected) {
_selected.remove(multiselectPart)
} else {
_selected.add(multiselectPart)
}
}
private inner class ConversationUpdateViewHolder(itemView: View) : ConversationViewHolder<ConversationUpdate>(itemView) {
override fun bind(model: ConversationUpdate) {
bindable.setEventListener(clickListener)
@@ -294,6 +318,20 @@ class ConversationAdapterV2(
protected val displayMode: ConversationItemDisplayMode
get() = condensedMode ?: ConversationItemDisplayMode.STANDARD
init {
itemView.setOnClickListener {
clickListener.onItemClick(bindable.getMultiselectPartForLatestTouch())
}
itemView.setOnLongClickListener {
clickListener.onItemLongClick(
it,
bindable.getMultiselectPartForLatestTouch()
)
true
}
}
override fun showProjectionArea() {
bindable.showProjectionArea()
}
@@ -326,4 +364,75 @@ class ConversationAdapterV2(
return bindable.getColorizerProjections(coordinateRoot)
}
}
inner class ThreadHeaderViewHolder(itemView: View) : MappingViewHolder<ThreadHeader>(itemView) {
private val conversationBanner: ConversationBannerView = itemView as ConversationBannerView
override fun bind(model: ThreadHeader) {
val (recipient, groupInfo, sharedGroups, messageRequestState) = model.recipientInfo
val isSelf = recipient.id == Recipient.self().id
conversationBanner.setAvatar(glideRequests, recipient)
conversationBanner.showBackgroundBubble(recipient.hasWallpaper())
val title: String = conversationBanner.setTitle(recipient)
conversationBanner.setAbout(recipient)
if (recipient.isGroup) {
if (groupInfo.pendingMemberCount > 0) {
val invited = context.resources.getQuantityString(R.plurals.MessageRequestProfileView_invited, groupInfo.pendingMemberCount, groupInfo.pendingMemberCount)
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members_and_invited, groupInfo.fullMemberCount, groupInfo.fullMemberCount, invited))
} else if (groupInfo.fullMemberCount > 0) {
conversationBanner.setSubtitle(context.resources.getQuantityString(R.plurals.MessageRequestProfileView_members, groupInfo.fullMemberCount, groupInfo.fullMemberCount))
} else {
conversationBanner.setSubtitle(null)
}
} else if (isSelf) {
conversationBanner.setSubtitle(context.getString(R.string.ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation))
} else {
val subtitle: String? = recipient.e164.map { e164: String? -> PhoneNumberFormatter.prettyPrint(e164!!) }.orElse(null)
if (subtitle == null || subtitle == title) {
conversationBanner.hideSubtitle()
} else {
conversationBanner.setSubtitle(subtitle)
}
}
if (sharedGroups.isEmpty() || isSelf) {
if (TextUtils.isEmpty(groupInfo.description)) {
conversationBanner.setLinkifyDescription(false)
conversationBanner.hideDescription()
} else {
conversationBanner.setLinkifyDescription(true)
val linkifyWebLinks = messageRequestState == MessageRequestState.NONE
conversationBanner.showDescription()
GroupDescriptionUtil.setText(
context,
conversationBanner.description,
groupInfo.description,
linkifyWebLinks
) {
clickListener.onShowGroupDescriptionClicked(recipient.getDisplayName(context), groupInfo.description, linkifyWebLinks)
}
}
} else {
val description: String = when (sharedGroups.size) {
1 -> context.getString(R.string.MessageRequestProfileView_member_of_one_group, HtmlUtil.bold(sharedGroups[0]))
2 -> context.getString(R.string.MessageRequestProfileView_member_of_two_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]))
3 -> context.getString(R.string.MessageRequestProfileView_member_of_many_groups, HtmlUtil.bold(sharedGroups[0]), HtmlUtil.bold(sharedGroups[1]), HtmlUtil.bold(sharedGroups[2]))
else -> {
val others: Int = sharedGroups.size - 2
context.getString(
R.string.MessageRequestProfileView_member_of_many_groups,
HtmlUtil.bold(sharedGroups[0]),
HtmlUtil.bold(sharedGroups[1]),
context.resources.getQuantityString(R.plurals.MessageRequestProfileView_member_of_d_additional_groups, others, others)
)
}
}
conversationBanner.setDescription(HtmlCompat.fromHtml(description, 0))
conversationBanner.showDescription()
}
}
}
}

View File

@@ -112,4 +112,26 @@ object ConversationDialogs {
}
.show()
}
@JvmStatic
fun displayDeleteDialog(context: Context, recipient: Recipient, onDelete: () -> Unit) {
MaterialAlertDialogBuilder(context)
.setNeutralButton(R.string.ConversationActivity_cancel, null)
.apply {
if (recipient.isGroup && recipient.isBlocked) {
setTitle(R.string.ConversationActivity_delete_conversation)
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
setPositiveButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
} else if (recipient.isGroup) {
setTitle(R.string.ConversationActivity_delete_and_leave_group)
setMessage(R.string.ConversationActivity_you_will_leave_this_group_and_it_will_be_deleted_from_all_of_your_devices)
setNegativeButton(R.string.ConversationActivity_delete_and_leave) { _, _ -> onDelete() }
} else {
setTitle(R.string.ConversationActivity_delete_conversation)
setMessage(R.string.ConversationActivity_this_conversation_will_be_deleted_from_all_of_your_devices)
setNegativeButton(R.string.ConversationActivity_delete) { _, _ -> onDelete() }
}
}
.show()
}
}

View File

@@ -4,7 +4,9 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.Optional
class ConversationRecipientRepository(threadId: Long) {
@@ -21,4 +23,20 @@ class ConversationRecipientRepository(threadId: Long) {
.refCount()
.observeOn(Schedulers.io())
}
val groupRecord: Observable<Optional<GroupRecord>> by lazy {
conversationRecipient
.switchMapSingle {
Single.fromCallable {
if (it.isGroup) {
SignalDatabase.groups.getGroup(it.id)
} else {
Optional.empty()
}
}
}
.replay(1)
.refCount()
.observeOn(Schedulers.io())
}
}

View File

@@ -1,11 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation.v2
import android.content.Context
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.libsignal.protocol.InvalidMessageException
import org.signal.paging.PagedData
import org.signal.paging.PagingConfig
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
@@ -13,11 +20,21 @@ import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.conversation.v2.data.ConversationDataSource
import org.thoughtcrime.securesms.database.RxDatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientFormattingException
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
class ConversationRepository(context: Context) {
@@ -29,9 +46,12 @@ class ConversationRepository(context: Context) {
*/
fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single<ConversationThreadState> {
return Single.fromCallable {
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!!
SignalLocalMetrics.ConversationOpen.onMetadataLoadStarted()
val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition)
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
val messageRequestData = metadata.messageRequestData
val dataSource = ConversationDataSource(
applicationContext,
@@ -48,9 +68,7 @@ class ConversationRepository(context: Context) {
ConversationThreadState(
items = PagedData.createForObservable(dataSource, config),
meta = metadata
).apply {
SignalLocalMetrics.ConversationOpen.onMetadataLoaded()
}
)
}.subscribeOn(Schedulers.io())
}
@@ -73,6 +91,58 @@ class ConversationRepository(context: Context) {
.subscribeOn(Schedulers.io())
}
fun sendMessage(
threadId: Long,
threadRecipient: Recipient?,
metricId: String?,
body: String,
slideDeck: SlideDeck?,
scheduledDate: Long,
messageToEdit: MessageId?,
quote: QuoteModel?,
mentions: List<Mention>,
bodyRanges: BodyRangeList?
): Completable {
val sendCompletable = Completable.create { emitter ->
if (body.isEmpty() && slideDeck?.containsMediaSlide() != true) {
emitter.onError(InvalidMessageException("Message is empty!"))
return@create
}
if (threadRecipient == null) {
emitter.onError(RecipientFormattingException("Badly formatted"))
return@create
}
val message = OutgoingMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
body = body,
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
isUrgent = true,
isSecure = true,
bodyRanges = bodyRanges,
scheduledDate = scheduledDate,
outgoingQuote = quote,
messageToEdit = messageToEdit?.id ?: 0,
mentions = mentions
)
MessageSender.send(
ApplicationDependencies.getApplication(),
message,
threadId,
MessageSender.SendType.SIGNAL,
metricId
) {
emitter.onComplete()
}
}
return sendCompletable
.subscribeOn(Schedulers.io())
}
fun setLastVisibleMessageTimestamp(threadId: Long, lastVisibleMessageTimestamp: Long) {
SignalExecutors.BOUNDED.submit { SignalDatabase.threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) }
}

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