Compare commits

..

193 Commits

Author SHA1 Message Date
Nicholas Tinsley
551e5a0a25 Bump version to 6.32.3 2023-09-09 13:08:03 -04:00
Nicholas Tinsley
92d4a580c1 Updated language translations. 2023-09-09 13:07:48 -04:00
Greyson Parrelli
b367701a96 Fix bug where default reactions were dropped in AccountRecord. 2023-09-08 19:42:04 -04:00
Nicholas Tinsley
8595863afe Bump version to 6.32.2 2023-09-08 17:54:55 -04:00
Nicholas Tinsley
21492ed88e Updated language translations. 2023-09-08 17:54:36 -04:00
Nicholas Tinsley
4dc14ab7f9 Fix translation tool postprocessing bug. 2023-09-08 17:50:50 -04:00
Nicholas Tinsley
5caf3409db Match incremental MAC calculation. 2023-09-08 17:50:50 -04:00
Greyson Parrelli
1565c32162 Fix crash when opening license screen. 2023-09-07 13:51:55 -04:00
Nicholas Tinsley
45edb4e5da Bump version to 6.32.1 2023-09-07 11:06:31 -04:00
Nicholas Tinsley
5bf1c4f433 Updated baseline profile. 2023-09-07 11:04:46 -04:00
Nicholas Tinsley
3cc692d3fb Rotate the edit message feature flag. 2023-09-07 10:28:54 -04:00
Alex Hart
e42b2490f0 Rotate flag for civ2-text-only views. 2023-09-07 11:27:34 -03:00
Alex Hart
454b1f69ed Suppress LayoutTransition during scroll events. 2023-09-07 11:26:45 -03:00
Greyson Parrelli
b410756dfd Remove dashes from help option. 2023-09-07 10:17:09 -04:00
Nicholas
1458919549 Voice note playlist improvements. 2023-09-07 10:02:16 -04:00
Alex Hart
48ae8c2465 Utilize Bitmap shortcut on API29 and under. 2023-09-07 10:42:39 -03:00
Alex Hart
0a78bcb374 Remove payments beta tag. 2023-09-06 16:27:51 -04:00
Greyson Parrelli
61cdb48273 Fix issue where notification settings were slow to open. 2023-09-06 16:23:14 -04:00
Nicholas Tinsley
b3350b22b6 Remove extraneous "Learn more" from Payments screen. 2023-09-06 15:12:42 -04:00
Nicholas Tinsley
d35d22c7d8 Fix voice note playback for single voice notes. 2023-09-06 11:53:41 -04:00
Alex Hart
24cd11152b Prevent several re-layout calls in ConversationItem. 2023-09-06 11:18:36 -03:00
Nicholas Tinsley
d21254ac02 Bump version to 6.32.0 2023-09-06 10:15:12 -04:00
Nicholas Tinsley
70f08c806a Updated baseline profile. 2023-09-06 10:08:51 -04:00
Nicholas Tinsley
e7c3fb02e8 Updated language translations. 2023-09-06 09:57:00 -04:00
Alex Hart
3d3cf1d76e Add logging for slide enqueue and move dropped animation message to verbose. 2023-09-06 09:34:49 -04:00
Nicholas
2bf385fe38 Upgrade libsignal to 0.32.0 2023-09-06 09:34:49 -04:00
Nicholas
7ba595be55 Ignore Bluetooth devices with Watch in their product name.
Addresses #13141
2023-09-06 09:34:49 -04:00
Alex Hart
c45e79c588 Split reaction view updates to separate width from adding views. 2023-09-06 09:34:49 -04:00
Alex Hart
f37568b050 Stopgap for reaction display in conversation item v2. 2023-09-06 09:34:49 -04:00
Nicholas Tinsley
b5afc1cd1c Fix AttachmentCipherTest 2023-09-06 09:34:49 -04:00
Alex Hart
e9777ccfc6 Fix scroll button when only one giant message is displayed. 2023-09-06 09:34:49 -04:00
Alex Hart
898404fc65 Fix poor spacing of footer in short group text messages. 2023-09-06 09:34:49 -04:00
Alex Hart
131212b158 Fix improper bubble spacing caused by swipe to reply icon. 2023-09-06 09:34:49 -04:00
Greyson Parrelli
3f1d3149e9 Attempt to open db as read-write during error recovery.
Relates to #13034
2023-09-06 09:34:49 -04:00
Nicholas
bfc8b199b6 Hopefully prevent VoiceNotePlaybackService startup crash.
Addresses #13140
2023-09-06 09:34:49 -04:00
Alex Hart
6d4b487428 Update shortcut drawable to use content id. 2023-09-06 09:34:49 -04:00
Cody Henthorne
9337201ffb Prevent okhttp from auto-retrying attachment uploads. 2023-09-06 09:34:49 -04:00
Greyson Parrelli
494b2c6786 Add an index specifically for improving thread count perf. 2023-09-06 09:34:49 -04:00
Alex Hart
bc1c8032c1 Add support for shade and arbitrary overlay drawables to CIV2 Media items. 2023-09-06 09:34:49 -04:00
Alex Hart
21b0a4d370 Fix UriChatWallpaper loading issue where wrong thread was used for setting the imageView resource. 2023-09-06 09:34:49 -04:00
Alex Hart
133effccfc Move delegate creation to a lazy field. 2023-09-06 09:34:49 -04:00
Cody Henthorne
62b4ebc4a9 Fix mention excessive haptic feedback bug. 2023-09-06 09:34:49 -04:00
Cody Henthorne
12941ea19e Fix attachment editor and schedule message bar UI overlap bug. 2023-09-06 09:34:49 -04:00
Alex Hart
f94bd706a4 Fix sender name color. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
3cbbc29c00 Rotate the edit message feature flag. 2023-09-06 09:34:48 -04:00
Cody Henthorne
0827c18eeb Update edit message awareness bottom sheet copy. 2023-09-06 09:34:48 -04:00
Cody Henthorne
6c4ebc9f58 Fix incorrect type value being used for unknown storage records. 2023-09-06 09:34:48 -04:00
Alex Hart
1f2bfe8245 Replace internal setting for CIV2 TextOnly with a FeatureFlag. 2023-09-06 09:34:48 -04:00
Jim Gustafson
305d7485c1 Update to RingRTC v2.31.2 2023-09-06 09:34:48 -04:00
Alex Hart
4ded05bbd1 Implement groundwork for proper ConversationItemV2 payload processing. 2023-09-06 09:34:48 -04:00
Alex Hart
540a2b1876 ConversationItemV2 Quote support and various fixes. 2023-09-06 09:34:48 -04:00
Cody Henthorne
153d3ad388 Fix story group replies layout in RTL. 2023-09-06 09:34:48 -04:00
Alex Hart
a3e36d2453 Update target API to 33 2023-09-06 09:34:48 -04:00
Nicholas Tinsley
b9449a798b Increase Glide exception coverage. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
9da149a868 Convert DateUtils to kotlin, improve perf with caching. 2023-09-06 09:34:48 -04:00
Cody Henthorne
d505c00403 Add CDN3 upload and download support. 2023-09-06 09:34:48 -04:00
Nicholas Tinsley
4d7a0a361f Dismiss Voice Note player notification upon completion. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
e08e02ae80 Update Stopwatch to log fractional milliseconds. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
95c6f569d6 Fetch data in ConversationDataSource in parallel. 2023-09-06 09:34:48 -04:00
Nicholas Tinsley
e46759f436 Update view-once Toast string. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
b42dd5289b Remove unnecessary context args in slide creation. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
a911a007d2 Change job scheduling to be relative rather than absolute. 2023-09-06 09:34:48 -04:00
Nicholas
64babe2e42 Streamable Video. 2023-09-06 09:34:48 -04:00
Greyson Parrelli
099c94c215 Fix handling of some PNI initial contact flows. 2023-08-31 14:33:54 -04:00
Alex Hart
75b81a0fd2 Add the groundwork for the ConversationItemV2 Media item. 2023-08-31 14:33:54 -04:00
Greyson Parrelli
f9ab5d4013 Fix SVR2 typo. 2023-08-31 14:33:53 -04:00
Cody Henthorne
b83080e2d7 Fix payments spinning forever. 2023-08-31 14:33:53 -04:00
Cody Henthorne
6a21106347 Convert StorageService protos to wire. 2023-08-31 14:33:53 -04:00
Greyson Parrelli
9a7d8c858d Convert JobDatabase to Kotlin. 2023-08-31 14:33:53 -04:00
Greyson Parrelli
8339c0d8de Convert JobManager tests to kotlin. 2023-08-29 09:33:45 -04:00
Greyson Parrelli
2b1136ea02 Fix loading states for username editing. 2023-08-29 09:33:45 -04:00
Greyson Parrelli
84b4d69913 Fix error display when entering invalid username characters.
Also convert UsernameEditViewModel to kotlin.
2023-08-29 09:33:45 -04:00
Alex Hart
3fe9ce378e Mock out glideRequests dependency for instrumented test. 2023-08-29 09:33:45 -04:00
Greyson Parrelli
57b9571d86 Don't store blank usernames. 2023-08-29 09:33:45 -04:00
Alex Hart
ae3071d318 Fix bottom constraint of sender photo in civ2. 2023-08-29 09:33:45 -04:00
Greyson Parrelli
8a93814bac Update to the new username link spec. 2023-08-29 09:33:45 -04:00
Alex Hart
a6dd4345ab Rewrite quote view using constraint layout and stubs. 2023-08-29 09:33:45 -04:00
Greyson Parrelli
c71456444f Bump version to 6.31.2 2023-08-28 18:58:47 -04:00
Greyson Parrelli
b916605a24 Updated language translations. 2023-08-28 18:58:23 -04:00
Alex Hart
553da1e7e8 Speed up AvatarProvider. 2023-08-28 18:51:43 -04:00
Cody Henthorne
847651ead7 Revert "Update to RingRTC v2.31.1"
This reverts commit 4ab82c99a8.
2023-08-28 11:55:41 -04:00
Alex Hart
f977f261d6 Utilize iconless person objects until we can fix AvatarProvider. 2023-08-28 12:52:45 -03:00
Greyson Parrelli
3fa9e89e8e Fix typo when reading feature flag. 2023-08-28 10:01:58 -04:00
Cody Henthorne
0662959e1d Bump version to 6.31.1 2023-08-25 16:34:48 -04:00
Cody Henthorne
e5e03f9693 Updated baseline profile. 2023-08-25 16:27:39 -04:00
Cody Henthorne
4203900365 Updated language translations. 2023-08-25 16:24:57 -04:00
Greyson Parrelli
eb7794ba47 Fix flag for battery saver prompt, enable for internal users. 2023-08-25 11:22:29 -04:00
Alex Hart
9626f33768 Fix viewer count in story viewer. 2023-08-25 11:57:28 -03:00
Cody Henthorne
cfc0ace41e Bump version to 6.31.0 2023-08-24 16:05:00 -04:00
Cody Henthorne
ce2947c756 Updated baseline profile. 2023-08-24 15:58:30 -04:00
Cody Henthorne
87fc10ad24 Updated language translations. 2023-08-24 15:34:07 -04:00
Greyson Parrelli
cae71559a0 Updated libphonenumber to 8.13.19 2023-08-24 15:11:54 -04:00
Cody Henthorne
3cf7920a22 Fix various media send failed to compress bugs. 2023-08-24 15:11:54 -04:00
Cody Henthorne
fba9b46fe9 Convert Provisioning, ResumeableUploads, and StickerResources protos to wire. 2023-08-24 15:11:54 -04:00
Alex Hart
611f074a9d Add main thread assertion for setting call status. 2023-08-24 15:11:54 -04:00
Cody Henthorne
7909703f4c Convert CDSI, KBS, and WebSocket protos to wire. 2023-08-24 15:11:54 -04:00
Cody Henthorne
dcbf4b8faf Prevent empty message sends with enter-key sends enabled. 2023-08-24 15:11:54 -04:00
Cody Henthorne
c5edcf47bd Rotate edit message flag. 2023-08-24 15:11:54 -04:00
Alex Hart
02e6b89fdd Fix message clustering for CIV2. 2023-08-24 15:11:54 -04:00
Alex Hart
c4109a19d6 Extract V2TextOnlyViewHolder to its own file. 2023-08-24 15:11:54 -04:00
Alex Hart
630d9492cd Add proper context menu positioning for CIV2. 2023-08-24 15:11:54 -04:00
Alex Hart
b762d95622 Fix issue where StoryPostFragment tries to post updates after fragment is detached from Context. 2023-08-24 15:11:54 -04:00
Alex Hart
3738997832 Add proper click handling support to ConversationItem V2. 2023-08-24 15:11:54 -04:00
Alex Hart
21c70039f4 Upgrade Compose BOM to 23.08.00 2023-08-23 09:29:48 -04:00
Cody Henthorne
23e3385290 Remove unused resources. 2023-08-23 09:29:48 -04:00
Jim Gustafson
4ab82c99a8 Update to RingRTC v2.31.1 2023-08-23 09:29:48 -04:00
Alex Hart
f4df37da23 Compute ConversationItem dates in the background. 2023-08-23 09:29:48 -04:00
Alex Hart
4494d8652d Add several performance improvements to ConversationItemV2. 2023-08-23 09:29:48 -04:00
Alex Hart
32ae4393e2 Fix issue with CIV2 where avatars would not load. 2023-08-23 09:29:48 -04:00
Alex Hart
ea5c3a7c5e Update compileSdk to 34. 2023-08-23 09:29:48 -04:00
Cody Henthorne
f9d9af4fe9 Bump version to 6.30.4 2023-08-22 20:30:44 -04:00
Cody Henthorne
098ef61b5d Updated baseline profile. 2023-08-22 20:24:11 -04:00
Cody Henthorne
e926f56f6b Updated language translations. 2023-08-22 20:21:02 -04:00
Cody Henthorne
9b1da3cfa0 Revert "Do not manually handle orientation changes in ConversationActivity."
This reverts commit 8b2a535f19.
2023-08-22 19:49:39 -04:00
Alex Hart
1fbcd9b362 Fix possible threading issue causing issues in group calls. 2023-08-22 15:15:48 -03:00
Alex Hart
38940e0111 Hopeful fix for remote notification crash. 2023-08-22 15:02:16 -03:00
Cody Henthorne
4fa3570d1e Bump version to 6.30.3 2023-08-22 11:49:43 -04:00
Cody Henthorne
d1c78d5062 Updated baseline profile. 2023-08-22 11:41:23 -04:00
Cody Henthorne
c4862bdddf Updated language translations. 2023-08-22 11:38:36 -04:00
Nicholas Tinsley
2b8018727c Fix voice note earpiece playback. 2023-08-22 11:30:43 -04:00
Greyson Parrelli
e3be279f1f Do not allow the sending of whitespace-only messages. 2023-08-22 11:30:43 -04:00
Greyson Parrelli
1e6126d5be Downgrade some logs and add a missing return. 2023-08-22 11:30:43 -04:00
Alex Hart
9a09708842 Use M3 Switch on EditProxyFragment. 2023-08-22 11:30:43 -04:00
Nicholas Tinsley
e861204cb0 Additional logging around incrementally digested uploads. 2023-08-22 11:30:43 -04:00
Alex Hart
afd3afcf0d Utilize M3 switch on chat color and wallpaper screen. 2023-08-22 11:30:43 -04:00
Alex Hart
5055b0c75d Fix rendering issue when opening the story info sheet too fast. 2023-08-22 11:30:43 -04:00
Alex Hart
372104cdfe Fix typing indicator rendering. 2023-08-21 14:22:08 -03:00
Cody Henthorne
acb24fd265 Bump version to 6.30.2 2023-08-18 17:07:03 -04:00
Cody Henthorne
5b7420ba90 Updated baseline profile. 2023-08-18 16:51:07 -04:00
Cody Henthorne
e73dbd5c15 Updated language translations. 2023-08-18 16:46:34 -04:00
Nicholas Tinsley
b5f82beb46 Revert "Fix contact photo upload failure."
This reverts commit 06dc8ccbdd.
2023-08-18 16:40:37 -04:00
Nicholas Tinsley
61b97fd09b Fix MediaController connection exception. 2023-08-18 16:40:37 -04:00
Cody Henthorne
99e34860d4 Increase vertial tap space for compose text to match bubble. 2023-08-18 16:40:37 -04:00
Cody Henthorne
5d44bbe956 Fix scroll jump when reacting with keyboard open. 2023-08-18 16:40:37 -04:00
Cody Henthorne
e7d0b575bb Reshow IME keyboard if it was showing prior to opening attachment keyboard. 2023-08-18 13:00:54 -04:00
Cody Henthorne
8b2a535f19 Do not manually handle orientation changes in ConversationActivity. 2023-08-18 12:34:50 -04:00
Alex Hart
a242dba345 Fix crash with improper fallback size. 2023-08-18 13:24:48 -03:00
Greyson Parrelli
587cb5de16 Fix unexpected SSE's.
Fixes #13115
2023-08-18 11:07:14 -04:00
Greyson Parrelli
e93c6957ac Fix crash in RecipientTable.getAllPnis() 2023-08-18 09:59:12 -04:00
Cody Henthorne
f644115b54 Bump version to 6.30.1 2023-08-17 16:45:36 -04:00
Cody Henthorne
0c753d22b6 Updated baseline profile. 2023-08-17 16:36:36 -04:00
Cody Henthorne
ec7f2c33e7 Updated language translations. 2023-08-17 16:31:38 -04:00
Cody Henthorne
39c1c1e371 Fix ANR-like bug when resuming MainActivity. 2023-08-17 15:02:16 -04:00
Greyson Parrelli
74d5faf3fa Allow PNI-only contact inserts. 2023-08-17 14:51:11 -04:00
Cody Henthorne
15204a2c84 Remove SignalServiceContent. 2023-08-17 14:43:42 -04:00
Nicholas Tinsley
2397cb5428 Fix play-pause button in video player. 2023-08-17 14:34:19 -04:00
Greyson Parrelli
4b6b87d632 Make ACI's optional on ContactRecords. 2023-08-17 14:33:18 -04:00
Alex Hart
2492b8de34 Fix AvatarProvider crash when user does not have a profile photo set. 2023-08-17 15:30:55 -03:00
Greyson Parrelli
635987a420 Add improved error logging for SSE issues. 2023-08-17 13:42:22 -04:00
Alex Hart
51602ed231 Wrap thread get/create into a transaction. 2023-08-17 14:38:45 -03:00
Alex Hart
25aab0f702 Clean up threadId -1 checks in Conversation code. 2023-08-17 14:18:32 -03:00
Greyson Parrelli
23b3c7f1fd Use a consistent SSE condition and use more breadcrums in logs. 2023-08-17 12:51:40 -04:00
Nicholas Tinsley
451ce74fa4 Safely run VoiceNoteProximityWakeLockManager cleanup. 2023-08-17 11:17:19 -04:00
Greyson Parrelli
1fd9609810 Improve logging around SSE exceptions. 2023-08-17 10:23:03 -04:00
Greyson Parrelli
29804e0a2b Add more logging to SVR2 failures. 2023-08-17 09:54:20 -04:00
Clark Chen
26aa7e8332 Bump version to 6.30.0 2023-08-16 17:49:40 -04:00
Clark Chen
e4e00be119 Updated language translations. 2023-08-16 17:33:09 -04:00
Clark Chen
de6b71528b Rotate edit message flag. 2023-08-16 17:06:04 -04:00
Greyson Parrelli
d005ace383 Add some more getAndPossiblyMerge tests. 2023-08-16 17:06:04 -04:00
Cody Henthorne
f566e10710 Drop V2 suffix from MCPv2 classes. 2023-08-16 17:06:04 -04:00
Alex Hart
18f9c6b1f0 Consolidate some constants and add kotlin target JVM version. 2023-08-16 15:29:45 -03:00
Cody Henthorne
fbf4de0ec5 Remove job-based decryption support and MCPv1. 2023-08-16 14:28:14 -04:00
Nicholas Tinsley
3d94122abc Null check for current audio device. 2023-08-16 12:40:35 -04:00
Greyson Parrelli
442a66df2e Update the groups tables to use foreign keys. 2023-08-16 12:23:54 -04:00
Clark
3be5d61ced Fix wrong thread crash when revoking message while editing. 2023-08-16 10:48:51 -04:00
Greyson Parrelli
f137e23b43 Split usernames into it's own feature flag for internal testing. 2023-08-16 10:46:07 -04:00
Greyson Parrelli
f00178cc0d Don't show the safety number and badges sections in note-to-self settings. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
e33c5b055d Fix FTS searches for punctuation and emoji.
Fixes #13047
2023-08-16 10:26:32 -04:00
Greyson Parrelli
f2237a385e Don't show safety number item for the release notes chat. 2023-08-16 10:26:32 -04:00
Nicholas
a9c45f7e78 Video streaming sample app. 2023-08-16 10:26:32 -04:00
Nicholas
11cfe5ee82 Upgrade to AndroidX Media3. 2023-08-16 10:26:32 -04:00
Clark
4cbcee85d6 Add prompt to help troubleshoot slow notifications. 2023-08-16 10:26:32 -04:00
Alex Hart
98ec2cceb4 Add content description to DeliveryStatusView. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
8ce05c8bbe Include urgent flag in delivery latency log. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
a7019b2e60 Rename PushNotificationReceiveJob -> MessageFetchJob. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
0facdc0497 Fix foreground service in PushNotificationReceiveJob. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
25a7560e2e Always attempt to clear FTS index for DB issues. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
063d909572 Log some debug info about image compression. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
2f8e112f3a Rename MessageProcessReceiver -> RoutineMessageFetchReceiver. 2023-08-16 10:26:32 -04:00
Alex Hart
99abfd0d98 Share to signal from CallSheet. 2023-08-16 10:26:32 -04:00
Greyson Parrelli
5fa9a27ee0 Convert WebSocketStrategy.java -> WebSocketDrainer.kt 2023-08-16 10:26:32 -04:00
Greyson Parrelli
b07d675bb4 Remove BackgroundMessageRetriever and clean up old code. 2023-08-16 10:26:32 -04:00
Alex Hart
9f75c37331 Upgrade Glide to 4.15.1 2023-08-16 10:26:31 -04:00
Greyson Parrelli
df96b05863 Improve table display in Spinner. 2023-08-16 10:26:31 -04:00
Greyson Parrelli
d6adfea9b1 Clean up old one-time prekeys. 2023-08-16 10:26:31 -04:00
Greyson Parrelli
389b439e9a Log ServiceId parsing failures. 2023-08-16 10:26:31 -04:00
Greyson Parrelli
046b89fa21 Update libsignal to 0.31.0 2023-08-16 10:26:31 -04:00
Greyson Parrelli
72e5532c6c Perform a legacy session reset if you fail to decrypt a sync message. 2023-08-16 10:26:31 -04:00
Greyson Parrelli
5688d85789 Do not send retry receipts for messages sent to our PNI. 2023-08-16 10:26:31 -04:00
Clark Chen
28b63e08f1 Bump version to 6.29.2 2023-08-15 15:45:35 -04:00
Clark Chen
951ce77853 Updated language translations. 2023-08-15 15:31:11 -04:00
Clark
b37ba63018 Revert remote delete/edit send threshold to 3 hours. 2023-08-15 12:28:59 -04:00
Clark
251d251661 Send read receipts per edit message revision. 2023-08-14 17:20:04 -04:00
722 changed files with 37002 additions and 45135 deletions

View File

@@ -10,7 +10,6 @@ plugins {
id 'app.cash.exhaustive'
id 'kotlin-parcelize'
id 'com.squareup.wire'
id 'android-constants'
id 'translations'
}
@@ -39,14 +38,18 @@ wire {
sourcePath {
srcDir 'src/main/protowire'
}
protoPath {
srcDir "${project.rootDir}/libsignal/service/src/main/protowire"
}
}
ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1312
def canonicalVersionName = "6.29.1"
def canonicalVersionCode = 1326
def canonicalVersionName = "6.32.3"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -94,7 +97,7 @@ android {
testBuildType 'instrumentation'
kotlinOptions {
jvmTarget = "11"
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = ["-Xallow-result-return-type"]
}
@@ -181,6 +184,7 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\""
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\""
@@ -195,6 +199,7 @@ android {
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
@@ -377,6 +382,7 @@ android {
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
@@ -511,7 +517,7 @@ dependencies {
implementation libs.google.play.services.maps
implementation libs.google.play.services.auth
implementation libs.bundles.exoplayer
implementation libs.bundles.media3
implementation libs.conscrypt.android
implementation libs.signal.aesgcmprovider
@@ -590,6 +596,7 @@ dependencies {
testImplementation testLibs.robolectric.shadows.multidex
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
testImplementation (testLibs.bouncycastle.bcpkix.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
testImplementation testLibs.conscrypt.openjdk.uber // Used by robolectric
testImplementation testLibs.hamcrest.hamcrest
testImplementation testLibs.mockk

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

View File

@@ -50,7 +50,6 @@ class ChangeNumberViewModelTest {
@Before
fun setUp() {
ApplicationDependencies.getSignalServiceAccountManager().setSoTimeoutMillis(1000)
ThreadUtil.runOnMainSync {
viewModel = ChangeNumberViewModel(
localNumber = harness.self.requireE164(),

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
import android.net.Uri
import android.view.View
import androidx.lifecycle.Observer
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
@@ -28,6 +29,7 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.mms.GlideRequests
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -214,6 +216,8 @@ class V2ConversationItemShapeTest {
override val selectedItems: Set<MultiselectPart> = emptySet()
override val isMessageRequestAccepted: Boolean = true
override val searchQuery: String? = null
override val glideRequests: GlideRequests = mockk()
override val isParentInScroll: Boolean = false
override fun onStartExpirationTimeout(messageRecord: MessageRecord) = Unit

View File

@@ -134,6 +134,40 @@ class AttachmentTableTest {
highInfo.file.exists() assertIs true
}
/**
* Given: Three pre-upload attachments with the same data but different transform properties (1x standard and 2x high).
*
* When inserting content of high pre-upload attachment.
*
* Then do not deduplicate with standard pre-upload attachment, but do deduplicate second high insert.
*/
@Test
fun doNotDedupedFileIfUsedByAnotherAttachmentWithADifferentTransformProperties() {
// GIVEN
val uncompressData = byteArrayOf(1, 2, 3, 4, 5)
val blobUncompressed = BlobProvider.getInstance().forData(uncompressData).createForSingleSessionInMemory()
val standardQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.empty())
val standardDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(standardQualityPreUpload)
// WHEN
val highQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
val highDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityPreUpload)
val secondHighQualityPreUpload = createAttachment(1, blobUncompressed, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
val secondHighDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondHighQualityPreUpload)
// THEN
val standardInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(standardDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
val highInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(highDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
val secondHighInfo = SignalDatabase.attachments.getAttachmentDataFileInfo(secondHighDatabaseAttachment.attachmentId, AttachmentTable.DATA)!!
highInfo.file assertIsNot standardInfo.file
secondHighInfo.file assertIs highInfo.file
standardInfo.file.exists() assertIs true
highInfo.file.exists() assertIs true
}
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
return UriAttachmentBuilder.build(
id,

View File

@@ -0,0 +1,176 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import org.junit.Test
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.kem.KEMKeyPair
import org.signal.libsignal.protocol.kem.KEMKeyType
import org.signal.libsignal.protocol.state.KyberPreKeyRecord
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.UUID
class KyberPreKeyTableTest {
private val aci: ACI = ACI.from(UUID.randomUUID())
private val pni: PNI = PNI.from(UUID.randomUUID())
@Test
fun markAllStaleIfNecessary_onlyUpdatesMatchingAccountAndZeroValues() {
insertTestRecord(aci, id = 1)
insertTestRecord(aci, id = 2)
insertTestRecord(aci, id = 3, staleTime = 42)
insertTestRecord(pni, id = 4)
val now = System.currentTimeMillis()
SignalDatabase.kyberPreKeys.markAllStaleIfNecessary(aci, now)
assertEquals(now, getStaleTime(aci, 1))
assertEquals(now, getStaleTime(aci, 2))
assertEquals(42L, getStaleTime(aci, 3))
assertEquals(0L, getStaleTime(pni, 4))
}
@Test
fun deleteAllStaleBefore_deleteOldBeforeThreshold() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(aci, id = 4, staleTime = 15)
insertTestRecord(aci, id = 5, staleTime = 0)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
assertNull(getStaleTime(aci, 1))
assertNull(getStaleTime(aci, 2))
assertNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_neverDeleteStaleOfZero() {
insertTestRecord(aci, id = 1, staleTime = 0)
insertTestRecord(aci, id = 2, staleTime = 0)
insertTestRecord(aci, id = 3, staleTime = 0)
insertTestRecord(aci, id = 4, staleTime = 0)
insertTestRecord(aci, id = 5, staleTime = 0)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 10, minCount = 1)
assertNotNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_respectMinCount() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(aci, id = 4, staleTime = 10)
insertTestRecord(aci, id = 5, staleTime = 10)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
assertNull(getStaleTime(aci, 1))
assertNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_respectAccount() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(pni, id = 4, staleTime = 10)
insertTestRecord(pni, id = 5, staleTime = 10)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 2)
assertNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(pni, 4))
assertNotNull(getStaleTime(pni, 5))
}
@Test
fun deleteAllStaleBefore_ignoreLastResortForMinCount() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(aci, id = 4, staleTime = 10)
insertTestRecord(aci, id = 5, staleTime = 10, lastResort = true)
SignalDatabase.kyberPreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
assertNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_neverDeleteLastResort() {
insertTestRecord(aci, id = 1, staleTime = 10, lastResort = true)
insertTestRecord(aci, id = 2, staleTime = 10, lastResort = true)
insertTestRecord(aci, id = 3, staleTime = 10, lastResort = true)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
assertNotNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
}
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0, lastResort: Boolean = false) {
val kemKeyPair = KEMKeyPair.generate(KEMKeyType.KYBER_1024)
SignalDatabase.kyberPreKeys.insert(
serviceId = account,
keyId = id,
record = KyberPreKeyRecord(
id,
System.currentTimeMillis(),
kemKeyPair,
Curve.generateKeyPair().privateKey.calculateSignature(kemKeyPair.publicKey.serialize())
),
lastResort = lastResort
)
val count = SignalDatabase.rawDatabase
.update(KyberPreKeyTable.TABLE_NAME)
.values(KyberPreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account)
.run()
assertEquals(1, count)
}
private fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.rawDatabase
.select(KyberPreKeyTable.STALE_TIMESTAMP)
.from(KyberPreKeyTable.TABLE_NAME)
.where("${KyberPreKeyTable.ACCOUNT_ID} = ? AND ${KyberPreKeyTable.KEY_ID} = $id", account)
.run()
.readToSingleObject { it.requireLongOrNull(KyberPreKeyTable.STALE_TIMESTAMP) }
}
}

View File

@@ -0,0 +1,137 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNotNull
import junit.framework.TestCase.assertNull
import org.junit.Test
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.UUID
class OneTimePreKeyTableTest {
private val aci: ACI = ACI.from(UUID.randomUUID())
private val pni: PNI = PNI.from(UUID.randomUUID())
@Test
fun markAllStaleIfNecessary_onlyUpdatesMatchingAccountAndZeroValues() {
insertTestRecord(aci, id = 1)
insertTestRecord(aci, id = 2)
insertTestRecord(aci, id = 3, staleTime = 42)
insertTestRecord(pni, id = 4)
val now = System.currentTimeMillis()
SignalDatabase.oneTimePreKeys.markAllStaleIfNecessary(aci, now)
assertEquals(now, getStaleTime(aci, 1))
assertEquals(now, getStaleTime(aci, 2))
assertEquals(42L, getStaleTime(aci, 3))
assertEquals(0L, getStaleTime(pni, 4))
}
@Test
fun deleteAllStaleBefore_deleteOldBeforeThreshold() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(aci, id = 4, staleTime = 15)
insertTestRecord(aci, id = 5, staleTime = 0)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 0)
assertNull(getStaleTime(aci, 1))
assertNull(getStaleTime(aci, 2))
assertNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_neverDeleteStaleOfZero() {
insertTestRecord(aci, id = 1, staleTime = 0)
insertTestRecord(aci, id = 2, staleTime = 0)
insertTestRecord(aci, id = 3, staleTime = 0)
insertTestRecord(aci, id = 4, staleTime = 0)
insertTestRecord(aci, id = 5, staleTime = 0)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 10, minCount = 0)
assertNotNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_respectMinCount() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(aci, id = 4, staleTime = 10)
insertTestRecord(aci, id = 5, staleTime = 10)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 3)
assertNull(getStaleTime(aci, 1))
assertNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(aci, 4))
assertNotNull(getStaleTime(aci, 5))
}
@Test
fun deleteAllStaleBefore_respectAccount() {
insertTestRecord(aci, id = 1, staleTime = 10)
insertTestRecord(aci, id = 2, staleTime = 10)
insertTestRecord(aci, id = 3, staleTime = 10)
insertTestRecord(pni, id = 4, staleTime = 10)
insertTestRecord(pni, id = 5, staleTime = 10)
SignalDatabase.oneTimePreKeys.deleteAllStaleBefore(aci, threshold = 11, minCount = 2)
assertNull(getStaleTime(aci, 1))
assertNotNull(getStaleTime(aci, 2))
assertNotNull(getStaleTime(aci, 3))
assertNotNull(getStaleTime(pni, 4))
assertNotNull(getStaleTime(pni, 5))
}
private fun insertTestRecord(account: ServiceId, id: Int, staleTime: Long = 0) {
SignalDatabase.oneTimePreKeys.insert(
serviceId = account,
keyId = id,
record = PreKeyRecord(id, Curve.generateKeyPair())
)
val count = SignalDatabase.rawDatabase
.update(OneTimePreKeyTable.TABLE_NAME)
.values(OneTimePreKeyTable.STALE_TIMESTAMP to staleTime)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account)
.run()
assertEquals(1, count)
}
private fun getStaleTime(account: ServiceId, id: Int): Long? {
return SignalDatabase.rawDatabase
.select(OneTimePreKeyTable.STALE_TIMESTAMP)
.from(OneTimePreKeyTable.TABLE_NAME)
.where("${OneTimePreKeyTable.ACCOUNT_ID} = ? AND ${OneTimePreKeyTable.KEY_ID} = $id", account)
.run()
.readToSingleObject { it.requireLongOrNull(OneTimePreKeyTable.STALE_TIMESTAMP) }
}
}

View File

@@ -38,8 +38,8 @@ class RecipientTableTest_applyStorageSyncContactUpdate {
val newProto = oldRecord
.toProto()
.toBuilder()
.setIdentityState(ContactRecord.IdentityState.DEFAULT)
.newBuilder()
.identityState(ContactRecord.IdentityState.DEFAULT)
.build()
val newRecord = SignalContactRecord(oldRecord.id, newProto)

View File

@@ -60,21 +60,6 @@ class RecipientTableTest_getAndPossiblyMerge {
FeatureFlagsAccessor.forceValue(FeatureFlags.PHONE_NUMBER_PRIVACY, true)
}
@Test
fun single() {
test("merge, e164 + pni reassigned, aci abandoned") {
given(E164_A, PNI_A, ACI_A)
given(E164_B, PNI_B, ACI_B)
process(E164_A, PNI_A, ACI_B)
expect(null, null, ACI_A)
expect(E164_A, PNI_A, ACI_B)
expectChangeNumberEvent()
}
}
@Test
fun allNonMergeTests() {
test("e164-only insert") {
@@ -85,7 +70,7 @@ class RecipientTableTest_getAndPossiblyMerge {
assertEquals(RecipientTable.RegisteredState.UNKNOWN, record.registered)
}
test("pni-only insert", exception = IllegalArgumentException::class.java) {
test("pni-only insert") {
val id = process(null, PNI_A, null)
expect(null, PNI_A, null)
@@ -102,18 +87,27 @@ class RecipientTableTest_getAndPossiblyMerge {
}
test("e164+pni insert") {
process(E164_A, PNI_A, null)
val id = process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
test("e164+aci insert") {
process(E164_A, null, ACI_A)
val id = process(E164_A, null, ACI_A)
expect(E164_A, null, ACI_A)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
test("e164+pni+aci insert") {
process(E164_A, PNI_A, ACI_A)
val id = process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
}
@@ -124,9 +118,9 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(E164_A, null, null)
}
test("no match, e164 and pni") {
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
test("no match, pni-only") {
process(null, PNI_A, null)
expect(null, PNI_A, null)
}
test("no match, aci-only") {
@@ -134,6 +128,11 @@ class RecipientTableTest_getAndPossiblyMerge {
expect(null, null, ACI_A)
}
test("no match, e164 and pni") {
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
}
test("no match, e164 and aci") {
process(E164_A, null, ACI_A)
expect(E164_A, null, ACI_A)
@@ -410,7 +409,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164 & pni+e164, no aci provided") {
test("steal, e164 & pni+e164, no aci provided, pni session exists") {
val id1 = given(E164_A, null, null)
val id2 = given(E164_B, PNI_A, null, pniSession = true)
@@ -423,6 +422,16 @@ class RecipientTableTest_getAndPossiblyMerge {
expectSessionSwitchoverEvent(id2, E164_B)
}
test("steal, e164 & pni+e164, no aci provided, no pni session") {
given(E164_A, null, null)
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, null)
expect(E164_B, null, null)
}
test("steal, e164+pni+aci & e164+aci, no pni provided, change number") {
given(E164_A, PNI_A, ACI_A)
given(E164_B, null, ACI_B)
@@ -435,6 +444,64 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, e164+aci & aci, no pni provided, existing aci session") {
given(E164_A, null, ACI_A, aciSession = true)
given(null, null, ACI_B)
process(E164_A, null, ACI_B)
expect(null, null, ACI_A)
expect(E164_A, null, ACI_B)
expectNoSessionSwitchoverEvent()
}
test("steal, e164+pni+aci & aci, no pni provided, existing aci session") {
given(E164_A, PNI_A, ACI_A, aciSession = true)
given(null, null, ACI_B)
process(E164_A, null, ACI_B)
expect(null, PNI_A, ACI_A)
expect(E164_A, null, ACI_B)
expectNoSessionSwitchoverEvent()
}
test("steal, e164+pni+aci & aci, no pni provided, existing pni session") {
given(E164_A, PNI_A, ACI_A, pniSession = true)
given(null, null, ACI_B)
process(E164_A, null, ACI_B)
expect(null, PNI_A, ACI_A)
expect(E164_A, null, ACI_B)
expectNoSessionSwitchoverEvent()
}
test("steal, e164+pni & aci, no pni provided, no pni session") {
given(E164_A, PNI_A, null)
given(null, null, ACI_A)
process(E164_A, null, ACI_A)
expect(null, PNI_A, null)
expect(E164_A, null, ACI_A)
}
test("steal, e164+pni & aci, no pni provided, pni session") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
process(E164_A, null, ACI_A)
expect(null, PNI_A, null)
expect(E164_A, null, ACI_A)
expectNoSessionSwitchoverEvent()
}
test("merge, e164 & pni & aci, all provided") {
given(E164_A, null, null)
given(null, PNI_A, null)
@@ -690,6 +757,22 @@ class RecipientTableTest_getAndPossiblyMerge {
process(E164_A, null, ACI_SELF, changeSelf = true)
expect(E164_A, null, ACI_SELF)
}
test("local user, local e164+aci provided, changeSelf=false, leave pni alone") {
given(E164_SELF, PNI_SELF, ACI_SELF)
process(E164_SELF, PNI_A, ACI_A)
expect(E164_SELF, PNI_SELF, ACI_SELF)
}
test("local user, local e164+aci provided, changeSelf=false, leave pni alone") {
given(E164_SELF, PNI_A, ACI_SELF)
process(E164_SELF, PNI_SELF, ACI_A)
expect(E164_SELF, PNI_A, ACI_SELF)
}
}
/**
@@ -942,7 +1025,8 @@ class RecipientTableTest_getAndPossiblyMerge {
pni: PNI?,
aci: ACI?,
createThread: Boolean = true,
pniSession: Boolean = false
pniSession: Boolean = false,
aciSession: Boolean = false
): RecipientId {
val id = insert(e164, pni, aci)
generatedIds += id
@@ -960,6 +1044,14 @@ class RecipientTableTest_getAndPossiblyMerge {
SignalDatabase.sessions.store(pni, SignalProtocolAddress(pni.toString(), 1), SessionRecord())
}
if (aciSession) {
if (aci == null) {
throw IllegalArgumentException("aciSession = true but aci is null!")
}
SignalDatabase.sessions.store(aci, SignalProtocolAddress(aci.toString(), 1), SessionRecord())
}
if (aci != null) {
SignalDatabase.identities.saveIdentity(
addressName = aci.toString(),

View File

@@ -0,0 +1,84 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.StreamUtil
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.Optional
import java.util.concurrent.CountDownLatch
@RunWith(AndroidJUnit4::class)
class AttachmentCompressionJobTest {
@get:Rule
val harness = SignalActivityRule()
@Test
fun testCompressionJobsWithDifferentTransformPropertiesCompleteSuccessfully() {
val imageBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("images/sample_image.png").use {
StreamUtil.readFully(it)
}
val blob = BlobProvider.getInstance().forData(imageBytes).createForSingleSessionOnDisk(ApplicationDependencies.getApplication())
val firstPreUpload = createAttachment(1, blob, AttachmentTable.TransformProperties.empty())
val firstDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(firstPreUpload)
val firstCompressionJob: AttachmentCompressionJob = AttachmentCompressionJob.fromAttachment(firstDatabaseAttachment, false, -1)
var secondCompressionJob: AttachmentCompressionJob? = null
var firstJobResult: Job.Result? = null
var secondJobResult: Job.Result? = null
val secondJobLatch = CountDownLatch(1)
val jobThread = Thread {
firstCompressionJob.setContext(ApplicationDependencies.getApplication())
firstJobResult = firstCompressionJob.run()
secondJobLatch.await()
secondCompressionJob!!.setContext(ApplicationDependencies.getApplication())
secondJobResult = secondCompressionJob!!.run()
}
jobThread.start()
val secondPreUpload = createAttachment(1, blob, AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH))
val secondDatabaseAttachment = SignalDatabase.attachments.insertAttachmentForPreUpload(secondPreUpload)
secondCompressionJob = AttachmentCompressionJob.fromAttachment(secondDatabaseAttachment, false, -1)
secondJobLatch.countDown()
jobThread.join()
firstJobResult!!.isSuccess assertIs true
secondJobResult!!.isSuccess assertIs true
}
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
return UriAttachmentBuilder.build(
id,
uri = uri,
contentType = MediaUtil.IMAGE_JPEG,
transformProperties = transformProperties
)
}
}

View File

@@ -32,7 +32,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.phoneNumberPrivacy().clearUsernameOutOfSync()
SignalStore.account().usernameOutOfSync = false
}
@Test
@@ -78,7 +78,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
@@ -108,7 +108,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
@@ -142,7 +142,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN
assertFalse(didReserve)
assertFalse(didConfirm)
assertFalse(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
@@ -176,6 +176,6 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
// THEN
assertTrue(didReserve)
assertFalse(didConfirm)
assertTrue(SignalStore.phoneNumberPrivacy().isUsernameOutOfSync)
assertTrue(SignalStore.account().usernameOutOfSync)
}
}

View File

@@ -46,13 +46,13 @@ class EditMessageSyncProcessorTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV2: MessageContentProcessorV2
private lateinit var processorV2: MessageContentProcessor
private lateinit var testResult: TestResults
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV2 = MessageContentProcessorV2(harness.context)
processorV2 = MessageContentProcessor(harness.context)
envelopeTimestamp = System.currentTimeMillis()
testResult = TestResults()
}

View File

@@ -1,62 +0,0 @@
package org.thoughtcrime.securesms.messages
import android.app.Application
import androidx.test.core.app.ApplicationProvider
import org.junit.Rule
import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata
import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.TestProtos
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
abstract class MessageContentProcessorTest {
@get:Rule
val harness = SignalActivityRule()
protected fun MessageContentProcessor.doProcess(
messageState: MessageState = MessageState.DECRYPTED_OK,
content: SignalServiceContent,
exceptionMetadata: ExceptionMetadata = ExceptionMetadata("sender", 1),
timestamp: Long = 100L,
smsMessageId: Long = -1L
) {
process(messageState, content, exceptionMetadata, timestamp, smsMessageId)
}
protected fun createNormalContentTestSubject(): MessageContentProcessor {
val context = ApplicationProvider.getApplicationContext<Application>()
return MessageContentProcessor.create(context)
}
/**
* Creates a valid ServiceContentProto with a data message which can be built via
* `injectDataMessage`. This function is intended to be built on-top of for more
* specific scenario in subclasses.
*
* Example can be seen in __handleStoryMessageTest
*/
protected fun createServiceContentWithDataMessage(
messageSender: Recipient = Recipient.resolved(harness.others.first()),
injectDataMessage: SignalServiceProtos.DataMessage.Builder.() -> Unit
): SignalServiceContentProto {
return TestProtos.build {
serviceContent(
localAddress = address(uuid = harness.self.requireServiceId().rawUuid).build(),
metadata = metadata(
address = address(uuid = messageSender.requireServiceId().rawUuid).build()
).build()
).apply {
content = content().apply {
dataMessage = dataMessage().apply {
injectDataMessage()
}.build()
}.build()
}.build()
}
}
}

View File

@@ -1,181 +0,0 @@
package org.thoughtcrime.securesms.messages
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.signal.core.util.requireLong
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MmsHelper
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.TestProtos
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
import kotlin.random.Random
@Suppress("ClassName")
class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorTest() {
@Before
fun setUp() {
SignalDatabase.messages.deleteAllThreads()
}
@After
fun tearDown() {
SignalDatabase.messages.deleteAllThreads()
}
@Test
fun givenContentWithADirectStoryReplyWhenIProcessThenIInsertAReplyInTheCorrectThread() {
val sender = Recipient.resolved(harness.others.first())
val senderThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(sender)
val myStory = Recipient.resolved(SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!!)
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
val expectedSentTime = 200L
val storyMessageId = MmsHelper.insert(
sentTimeMillis = expectedSentTime,
recipient = myStory,
storyType = StoryType.STORY_WITH_REPLIES,
threadId = myStoryThread
)
SignalDatabase.storySends.insert(
messageId = storyMessageId,
recipientIds = listOf(sender.id),
sentTimestamp = expectedSentTime,
allowsReplies = true,
distributionId = DistributionId.MY_STORY
)
val expectedBody = "Hello!"
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
messageSender = sender,
storyAuthor = harness.self,
storySentTimestamp = expectedSentTime
) {
body = expectedBody
}
runTestWithContent(contentProto = storyContent)
val replyId = SignalDatabase.messages.getConversation(senderThreadId, 0, 1).use {
it.moveToFirst()
it.requireLong(MessageTable.ID)
}
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
assertEquals(ParentStoryId.DirectReply(storyMessageId).serialize(), replyRecord.parentStoryId!!.serialize())
assertEquals(expectedBody, replyRecord.body)
SignalDatabase.messages.deleteAllThreads()
}
@Test
fun givenContentWithAGroupStoryReplyWhenIProcessThenIInsertAReplyToTheCorrectStory() {
val sender = Recipient.resolved(harness.others[0])
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.newBuilder()
.addAllMembers(
listOf(
DecryptedMember.newBuilder()
.setAciBytes(harness.self.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setAciBytes(sender.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
)
)
.setRevision(0)
.build()
val group = SignalDatabase.groups.create(
groupMasterKey,
decryptedGroupState
)
val groupRecipient = Recipient.externalGroupExact(group!!)
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val insertResult = MmsHelper.insert(
message = IncomingMediaMessage(
from = sender.id,
sentTimeMillis = 100L,
serverTimeMillis = 101L,
receivedTimeMillis = 102L,
storyType = StoryType.STORY_WITH_REPLIES
),
threadId = threadForGroup
)
val expectedBody = "Hello, World!"
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
messageSender = sender,
storyAuthor = sender,
storySentTimestamp = 100L
) {
groupV2 = TestProtos.build { groupContextV2(masterKeyBytes = groupMasterKey.serialize()).build() }
body = expectedBody
}
runTestWithContent(storyContent)
val replyId = SignalDatabase.messages.getStoryReplies(insertResult.get().messageId).use { cursor ->
assertEquals(1, cursor.count)
cursor.moveToFirst()
cursor.requireLong(MessageTable.ID)
}
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
assertEquals(ParentStoryId.GroupReply(insertResult.get().messageId).serialize(), replyRecord.parentStoryId?.serialize())
assertEquals(threadForGroup, replyRecord.threadId)
assertEquals(expectedBody, replyRecord.body)
SignalDatabase.messages.deleteGroupStoryReplies(insertResult.get().messageId)
SignalDatabase.messages.deleteAllThreads()
}
/**
* Creates a ServiceContent proto with a StoryContext, and then
* uses `injectDataMessage` to fill in the data message object.
*/
private fun createServiceContentWithStoryContext(
messageSender: Recipient,
storyAuthor: Recipient,
storySentTimestamp: Long,
injectDataMessage: DataMessage.Builder.() -> Unit
): SignalServiceContentProto {
return createServiceContentWithDataMessage(messageSender) {
storyContext = TestProtos.build {
storyContext(
sentTimestamp = storySentTimestamp,
authorUuid = storyAuthor.requireServiceId().toString()
).build()
}
injectDataMessage()
}
}
private fun runTestWithContent(contentProto: SignalServiceContentProto) {
val content = SignalServiceContent.createFromProto(contentProto)
val testSubject = createNormalContentTestSubject()
testSubject.doProcess(content = content!!)
}
}

View File

@@ -1,33 +0,0 @@
package org.thoughtcrime.securesms.messages
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.thoughtcrime.securesms.database.SignalDatabase
import org.whispersystems.signalservice.api.messages.SignalServiceContent
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
@Suppress("ClassName")
class MessageContentProcessor__handleTextMessageTest : MessageContentProcessorTest() {
@Test
fun givenContentWithATextMessageWhenIProcessThenIInsertTheTextMessage() {
val testSubject: MessageContentProcessor = createNormalContentTestSubject()
val expectedBody = "Hello, World!"
val contentProto: SignalServiceContentProto = createServiceContentWithDataMessage {
body = expectedBody
}
val content = SignalServiceContent.createFromProto(contentProto)
// WHEN
testSubject.doProcess(content = content!!)
// THEN
val record = SignalDatabase.messages.getMessageRecord(1)
val threadSize = SignalDatabase.messages.getMessageCountForThread(record.threadId)
assertEquals(1, threadSize)
assertTrue(record.isSecure)
assertEquals(expectedBody, record.body)
}
}

View File

@@ -20,17 +20,17 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupC
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class MessageContentProcessorV2__recipientStatusTest {
class MessageContentProcessor__recipientStatusTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var processorV2: MessageContentProcessorV2
private lateinit var processor: MessageContentProcessor
private var envelopeTimestamp: Long = 0
@Before
fun setup() {
processorV2 = MessageContentProcessorV2(harness.context)
processor = MessageContentProcessor(harness.context)
envelopeTimestamp = System.currentTimeMillis()
}
@@ -49,7 +49,7 @@ class MessageContentProcessorV2__recipientStatusTest {
timestamp = envelopeTimestamp
}
processorV2.process(
processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0])),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),
@@ -61,7 +61,7 @@ class MessageContentProcessorV2__recipientStatusTest {
val firstMessageId = firstSyncMessages[0].id
val firstReceiptInfo = SignalDatabase.groupReceipts.getGroupReceiptInfo(firstMessageId)
processorV2.process(
processor.process(
envelope = MessageContentFuzzer.envelope(envelopeTimestamp),
content = MessageContentFuzzer.syncSentTextMessage(initialTextMessage, deliveredTo = listOf(harness.others[0], harness.others[1]), recipientUpdate = true),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, groupId),

View File

@@ -27,8 +27,8 @@ import org.thoughtcrime.securesms.testing.FakeClientHelpers
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.awaitFor
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketProtos.WebSocketRequestMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.util.regex.Pattern
import kotlin.random.Random
import kotlin.time.Duration.Companion.minutes
@@ -59,14 +59,14 @@ class MessageProcessingPerformanceTest {
mockkStatic(UnidentifiedAccessUtil::class)
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkObject(MessageContentProcessorV2)
every { MessageContentProcessorV2.create(harness.application) } returns TimingMessageContentProcessorV2(harness.application)
mockkObject(MessageContentProcessor)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
}
@After
fun after() {
unmockkStatic(UnidentifiedAccessUtil::class)
unmockkStatic(MessageContentProcessorV2::class)
unmockkStatic(MessageContentProcessor::class)
}
@Test
@@ -107,7 +107,7 @@ class MessageProcessingPerformanceTest {
// Wait until they've all been fully decrypted + processed
harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(lastTimestamp))
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(lastTimestamp))
.awaitFor(1.minutes)
harness.inMemoryLogger.flush()
@@ -126,7 +126,7 @@ class MessageProcessingPerformanceTest {
// Calculate MessageContentProcessor
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessorV2.TAG }.drop(2)
val takeLast: List<Entry> = entries.filter { it.tag == TimingMessageContentProcessor.TAG }.drop(2)
val iterator = takeLast.iterator()
var processCount = 0L
var processDuration = 0L
@@ -142,7 +142,7 @@ class MessageProcessingPerformanceTest {
// Calculate messages per second from "retrieving" first message post session initialization to processing last message
val start = entries.first { it.message == "Retrieved envelope! $firstTimestamp" }
val end = entries.first { it.message == TimingMessageContentProcessorV2.endTag(lastTimestamp) }
val end = entries.first { it.message == TimingMessageContentProcessor.endTag(lastTimestamp) }
val duration = (end.timestamp - start.timestamp).toFloat() / 1000f
val messagePerSecond = messageCount.toFloat() / duration
@@ -157,7 +157,7 @@ class MessageProcessingPerformanceTest {
val aliceProcessFirstMessageLatch = harness
.inMemoryLogger
.getLockForUntil(TimingMessageContentProcessorV2.endTagPredicate(firstPreKeyMessageTimestamp))
.getLockForUntil(TimingMessageContentProcessor.endTagPredicate(firstPreKeyMessageTimestamp))
Thread { aliceClient.process(encryptedEnvelope, System.currentTimeMillis()) }.start()
aliceProcessFirstMessageLatch.awaitFor(15.seconds)
@@ -179,32 +179,19 @@ class MessageProcessingPerformanceTest {
}
private fun webSocketTombstone(): ByteString {
return WebSocketMessage
.newBuilder()
.setRequest(
WebSocketRequestMessage.newBuilder()
.setVerb("PUT")
.setPath("/api/v1/queue/empty")
)
.build()
.toByteArray()
.toByteString()
return WebSocketMessage(request = WebSocketRequestMessage(verb = "PUT", path = "/api/v1/queue/empty")).encodeByteString()
}
private fun Envelope.toWebSocketPayload(): ByteString {
return WebSocketMessage
.newBuilder()
.setType(WebSocketMessage.Type.REQUEST)
.setRequest(
WebSocketRequestMessage.newBuilder()
.setVerb("PUT")
.setPath("/api/v1/message")
.setId(Random(System.currentTimeMillis()).nextLong())
.addHeaders("X-Signal-Timestamp: ${this.timestamp}")
.setBody(this.toByteString())
return WebSocketMessage(
type = WebSocketMessage.Type.REQUEST,
request = WebSocketRequestMessage(
verb = "PUT",
path = "/api/v1/message",
id = Random(System.currentTimeMillis()).nextLong(),
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
body = this.toByteArray().toByteString()
)
.build()
.toByteArray()
.toByteString()
).encodeByteString()
}
}

View File

@@ -7,9 +7,9 @@ import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
class TimingMessageContentProcessorV2(context: Context) : MessageContentProcessorV2(context) {
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
companion object {
val TAG = Log.tag(TimingMessageContentProcessorV2::class.java)
val TAG = Log.tag(TimingMessageContentProcessor::class.java)
fun endTagPredicate(timestamp: Long): LogPredicate = { entry ->
entry.tag == TAG && entry.message == endTag(timestamp)

View File

@@ -38,15 +38,21 @@ class ContactRecordProcessorTest {
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setAci(ACI_A.toString())
setUnregisteredAtTimestamp(100)
}
val remote1 = buildRecord(
STORAGE_ID_B,
ContactRecord(
aci = ACI_A.toString(),
unregisteredAtTimestamp = 100
)
)
val remote2 = buildRecord(STORAGE_ID_C) {
setPni(PNI_A.toString())
setE164(E164_A)
}
val remote2 = buildRecord(
STORAGE_ID_C,
ContactRecord(
pni = PNI_A.toString(),
e164 = E164_A
)
)
// WHEN
val subject = ContactRecordProcessor()
@@ -69,16 +75,22 @@ class ContactRecordProcessorTest {
val originalId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, PNI_A, E164_A)
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setAci(ACI_A.toString())
setUnregisteredAtTimestamp(0)
}
val remote1 = buildRecord(
STORAGE_ID_B,
ContactRecord(
aci = ACI_A.toString(),
unregisteredAtTimestamp = 0
)
)
val remote2 = buildRecord(STORAGE_ID_C) {
setAci(PNI_A.toString())
setPni(PNI_A.toString())
setE164(E164_A)
}
val remote2 = buildRecord(
STORAGE_ID_C,
ContactRecord(
aci = PNI_A.toString(),
pni = PNI_A.toString(),
e164 = E164_A
)
)
// WHEN
val subject = ContactRecordProcessor()
@@ -94,8 +106,8 @@ class ContactRecordProcessorTest {
assertEquals(byAci, byE164)
}
private fun buildRecord(id: StorageId, applyParams: ContactRecord.Builder.() -> ContactRecord.Builder): SignalContactRecord {
return SignalContactRecord(id, ContactRecord.getDefaultInstance().toBuilder().applyParams().build())
private fun buildRecord(id: StorageId, record: ContactRecord): SignalContactRecord {
return SignalContactRecord(id, record)
}
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {

View File

@@ -32,10 +32,10 @@ import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import java.lang.UnsupportedOperationException
import java.util.Optional
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.UnsupportedOperationException
/**
* Welcome to Bob's Client.
@@ -144,7 +144,6 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()
@@ -162,6 +161,8 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
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 deleteAllStaleOneTimeEcPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun markAllOneTimeEcPreKeysStaleIfNecessary(staleTime: Long) = 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()
@@ -171,8 +172,9 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun storeLastResortKyberPreKey(kyberPreKeyId: Int, kyberPreKeyRecord: KyberPreKeyRecord) = throw UnsupportedOperationException()
override fun removeKyberPreKey(kyberPreKeyId: Int) = throw UnsupportedOperationException()
override fun markAllOneTimeKyberPreKeysStaleIfNecessary(staleTime: Long) = throw UnsupportedOperationException()
override fun deleteAllStaleOneTimeKyberPreKeys(threshold: Long, minCount: Int) = throw UnsupportedOperationException()
override fun loadLastResortKyberPreKeys(): List<KyberPreKeyRecord> = throw UnsupportedOperationException()
override fun isMultiDevice(): Boolean = throw UnsupportedOperationException()
}
}

View File

@@ -1,11 +1,19 @@
package org.thoughtcrime.securesms.testing
import android.database.Cursor
import android.util.Base64
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasSize
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.not
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.select
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
@@ -53,3 +61,29 @@ fun CountDownLatch.awaitFor(duration: Duration) {
throw TimeoutException("Latch await took longer than ${duration.inWholeMilliseconds}ms")
}
}
fun dumpTableToLogs(tag: String = "TestUtils", table: String) {
dumpTable(table).forEach { Log.d(tag, it.toString()) }
}
fun dumpTable(table: String): List<List<Pair<String, String?>>> {
return SignalDatabase.rawDatabase
.select()
.from(table)
.run()
.readToList { cursor ->
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
val index = cursor.getColumnIndex(column)
var data: String? = when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data
}
map
}
}

View File

@@ -44,6 +44,8 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.READ_CALL_STATE"/>
@@ -94,6 +96,10 @@
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application android:name=".ApplicationContext"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -1134,9 +1140,11 @@
<service
android:name=".components.voice.VoiceNotePlaybackService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
<action android:name="androidx.media3.session.MediaSessionService"/>
</intent-filter>
</service>
@@ -1323,7 +1331,7 @@
</intent-filter>
</receiver>
<receiver android:name=".messageprocessingalarm.MessageProcessReceiver" android:exported="false">
<receiver android:name=".messageprocessingalarm.RoutineMessageFetchReceiver" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="org.thoughtcrime.securesms.action.PROCESS_MESSAGES" />

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,6 @@
package org.thoughtcrime.securesms;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -49,7 +48,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.gcm.FcmJobService;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
@@ -61,7 +59,6 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.jobs.RefreshSvrCredentialsJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
@@ -71,7 +68,7 @@ import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
@@ -93,6 +90,8 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.PowerManagerCompat;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -186,7 +185,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
.addNonBlocking(this::initializePeriodicTasks)
.addNonBlocking(this::initializeCircumvention)
.addNonBlocking(this::initializePendingMessages)
.addNonBlocking(this::initializeCleanup)
.addNonBlocking(this::initializeGlideCodecs)
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
@@ -406,7 +404,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
DirectoryRefreshListener.schedule(this);
LocalBackupListener.schedule(this);
RotateSenderCertificateListener.schedule(this);
MessageProcessReceiver.startOrUpdateAlarm(this);
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
if (BuildConfig.PLAY_STORE_DISABLED) {
UpdateApkRefreshListener.schedule(this);
@@ -449,18 +447,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
private void initializePendingMessages() {
if (TextSecurePreferences.getNeedsMessagePull(this)) {
Log.i(TAG, "Scheduling a message fetch.");
if (Build.VERSION.SDK_INT >= 26) {
FcmJobService.schedule(this);
} else {
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
}
TextSecurePreferences.setNeedsMessagePull(this, false);
}
}
@WorkerThread
private void initializeBlobProvider() {
BlobProvider.getInstance().initialize(this);

View File

@@ -57,6 +57,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void setEventListener(@Nullable EventListener listener);
default void setParentScrolling(boolean isParentScrolling) {
// Intentionally Blank.
}
default void updateTimestamps() {
// Intentionally Blank.
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
@@ -15,14 +16,16 @@ import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics;
import org.thoughtcrime.securesms.notifications.SlowNotificationsViewModel;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -42,6 +45,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
private VoiceNoteMediaController mediaController;
private ConversationListTabsViewModel conversationListTabsViewModel;
private SlowNotificationsViewModel slowNotificationsViewModel;
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
private boolean onFirstRender = false;
@@ -76,6 +82,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
});
lifecycleDisposable.bindTo(this);
mediaController = new VoiceNoteMediaController(this, true);
@@ -91,6 +98,28 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
updateTabVisibility();
slowNotificationsViewModel = new ViewModelProvider(this).get(SlowNotificationsViewModel.class);
lifecycleDisposable.add(
slowNotificationsViewModel
.getSlowNotificationState()
.subscribe(this::presentSlowNotificationState)
);
}
@SuppressLint("NewApi")
private void presentSlowNotificationState(SlowNotificationsViewModel.State slowNotificationState) {
switch (slowNotificationState) {
case NONE:
break;
case PROMPT_BATTERY_SAVER_DIALOG:
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
break;
case PROMPT_DEBUGLOGS:
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
break;
}
}
@Override
@@ -139,9 +168,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
updateTabVisibility();
if (SlowNotificationHeuristics.shouldPromptUserForLogs()) {
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager());
}
slowNotificationsViewModel.checkSlowNotificationHeuristics();
}
@Override

View File

@@ -19,7 +19,6 @@ import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNum
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
@@ -80,15 +79,6 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
protected void onPreCreate() {}
protected void onCreate(Bundle savedInstanceState, boolean ready) {}
@Override
protected void onResume() {
super.onResume();
if (networkAccess.isCensored()) {
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
}
}
@Override
protected void onDestroy() {
super.onDestroy();

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.FeatureFlags;
import java.util.Comparator;
@@ -60,7 +61,7 @@ public class DatabaseAttachment extends Attachment {
@Override
@Nullable
public Uri getUri() {
if (hasData) {
if (hasData || (FeatureFlags.instantVideoPlayback() && getIncrementalDigest() != null)) {
return PartAuthority.getAttachmentDataUri(attachmentId);
} else {
return null;

View File

@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
import org.thoughtcrime.securesms.groups.ParcelableGroupId
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -238,7 +239,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
@Suppress("DEPRECATION")
private fun openGallery() {
Permissions.with(this)
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
.request(*PermissionCompat.forImages())
.ifNecessary()
.onAllGranted {
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())

View File

@@ -95,8 +95,10 @@ class BadgeImageView @JvmOverloads constructor(
}
private fun clearDrawable() {
setImageDrawable(null)
isClickable = false
if (drawable != null) {
setImageDrawable(null)
isClickable = false
}
}
private fun getGlideRequests(): GlideRequests? {

View File

@@ -59,73 +59,93 @@ class BadgeSpriteTransformation(
return outBitmap
}
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
enum class Size(val code: String) {
SMALL(
"small",
mapOf(
Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
)
),
"small"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
)
}
},
MEDIUM(
"medium",
mapOf(
Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
)
),
"medium"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
)
}
},
LARGE(
"large",
mapOf(
Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
)
),
"large"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
)
}
},
BADGE_64(
"badge_64",
mapOf(
Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
)
),
"badge_64"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
)
}
},
BADGE_112(
"badge_112",
mapOf(
Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
)
),
"badge_112"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
)
}
},
XLARGE(
"xlarge",
mapOf(
Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
)
);
"xlarge"
) {
override val frameMap: Map<Density, FrameSet> by lazy {
mapOf(
Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
)
}
};
abstract val frameMap: Map<Density, FrameSet>
companion object {
fun fromInteger(integer: Int): Size {

View File

@@ -54,7 +54,7 @@ object CallLinks {
@JvmStatic
fun isCallLink(url: String): Boolean {
if (FeatureFlags.adHocCalling()) {
if (!FeatureFlags.adHocCalling()) {
return false
}

View File

@@ -46,12 +46,10 @@ 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.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.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
@@ -212,15 +210,10 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
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()
)
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.linkKeyBytes))
)
)
}

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components
import android.content.Context
import android.graphics.Canvas
import android.graphics.Path
import android.graphics.Rect
import android.graphics.RectF
import android.util.AttributeSet
import androidx.core.graphics.withClip
@@ -18,15 +17,15 @@ class ClippedCardView @JvmOverloads constructor(
attrs: AttributeSet? = null
) : MaterialCardView(context, attrs) {
private val bounds = Rect()
private val boundsF = RectF()
private val path = Path()
override fun draw(canvas: Canvas) {
canvas.getClipBounds(bounds)
boundsF.set(bounds)
path.reset()
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
boundsF.set(0f, 0f, w.toFloat(), h.toFloat())
}
override fun draw(canvas: Canvas) {
path.reset()
path.addRoundRect(boundsF, radius, radius, Path.Direction.CW)
canvas.withClip(path) {
super.draw(canvas)

View File

@@ -326,7 +326,7 @@ public class ConversationItemFooter extends ConstraintLayout {
timestamp = messageRecord.getDateSent();
}
}
String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, timestamp);
String date = DateUtils.getDatelessRelativeTimeSpanString(getContext(), locale, timestamp);
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
}

View File

@@ -255,6 +255,14 @@ class ConversationItemThumbnail @JvmOverloads constructor(
state.applyState(thumbnail, album)
}
fun setProgressWheelClickListener(listener: SlideClickListener?) {
state = state.copy(
thumbnailViewState = state.thumbnailViewState.copy(progressWheelClickListener = listener)
)
state.applyState(thumbnail, album)
}
private fun setThumbnailBounds(bounds: IntArray) {
val (minWidth, maxWidth, minHeight, maxHeight) = bounds
state = state.copy(

View File

@@ -31,6 +31,8 @@ data class ConversationItemThumbnailState(
@IgnoredOnParcel
private val downloadClickListener: SlidesClickedListener? = null,
@IgnoredOnParcel
private val progressWheelClickListener: SlideClickListener? = null,
@IgnoredOnParcel
private val longClickListener: OnLongClickListener? = null,
private val visibility: Int = View.GONE,
private val minWidth: Int = -1,
@@ -55,6 +57,7 @@ data class ConversationItemThumbnailState(
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
thumbnailView.get().setThumbnailClickListener(clickListener)
thumbnailView.get().setDownloadClickListener(downloadClickListener)
thumbnailView.get().setProgressWheelClickListener(progressWheelClickListener)
thumbnailView.get().setOnLongClickListener(longClickListener)
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
}

View File

@@ -12,6 +12,7 @@ import android.view.animation.LinearInterpolator;
import android.view.animation.RotateAnimation;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.AppCompatImageView;
import org.signal.core.util.DimensionUnit;
@@ -133,6 +134,7 @@ public class DeliveryStatusView extends AppCompatImageView {
state = State.NONE;
clearAnimation();
setVisibility(View.GONE);
updateContentDescription();
}
public boolean isPending() {
@@ -145,6 +147,7 @@ public class DeliveryStatusView extends AppCompatImageView {
ViewUtil.setPaddingStart(this, 0);
ViewUtil.setPaddingEnd(this, horizontalPadding);
setImageResource(R.drawable.ic_delivery_status_sending);
updateContentDescription();
}
public void setSent() {
@@ -154,6 +157,7 @@ public class DeliveryStatusView extends AppCompatImageView {
ViewUtil.setPaddingEnd(this, 0);
clearAnimation();
setImageResource(R.drawable.ic_delivery_status_sent);
updateContentDescription();
}
public void setDelivered() {
@@ -163,6 +167,7 @@ public class DeliveryStatusView extends AppCompatImageView {
ViewUtil.setPaddingEnd(this, 0);
clearAnimation();
setImageResource(R.drawable.ic_delivery_status_delivered);
updateContentDescription();
}
public void setRead() {
@@ -172,23 +177,36 @@ public class DeliveryStatusView extends AppCompatImageView {
ViewUtil.setPaddingEnd(this, 0);
clearAnimation();
setImageResource(R.drawable.ic_delivery_status_read);
updateContentDescription();
}
public void setTint(int color) {
setColorFilter(color);
}
private void updateContentDescription() {
if (state.contentDescription == -1) {
setContentDescription(null);
} else {
setContentDescription(getContext().getString(state.contentDescription));
}
}
private enum State {
NONE(0),
PENDING(1),
SENT(2),
DELIVERED(3),
READ(4);
NONE(0, -1),
PENDING(1, R.string.message_details_recipient_header__pending_send),
SENT(2, R.string.message_details_header_sent),
DELIVERED(3, R.string.conversation_item_sent__delivered_description),
READ(4, R.string.conversation_item_sent__message_read);
final int code;
State(int code) {
this.code = code;
@StringRes
final int contentDescription;
State(int code, @StringRes int contentDescription) {
this.code = code;
this.contentDescription = contentDescription;
}
static State fromCode(int code) {

View File

@@ -25,6 +25,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
private var inputId: Int? = null
private var input: Fragment? = null
private var wasKeyboardVisibleBeforeToggle: Boolean = false
val isInputShowing: Boolean
get() = input != null
@@ -38,11 +39,12 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
}
fun hideAll(imeTarget: EditText) {
wasKeyboardVisibleBeforeToggle = false
ViewUtil.hideKeyboard(context, imeTarget)
hideInput(resetKeyboardGuideline = true)
}
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = false) {
fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) {
if (fragmentCreator.id == inputId) {
if (showSoftKeyOnHide) {
showSoftkey(imeTarget)
@@ -50,6 +52,7 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
hideInput(resetKeyboardGuideline = true)
}
} else {
wasKeyboardVisibleBeforeToggle = isKeyboardShowing
hideInput(resetKeyboardGuideline = false)
showInput(fragmentCreator, imeTarget)
}
@@ -57,6 +60,16 @@ class InputAwareConstraintLayout @JvmOverloads constructor(
fun hideInput() {
hideInput(resetKeyboardGuideline = true)
wasKeyboardVisibleBeforeToggle = false
}
fun hideKeyboard(imeTarget: EditText, keepHeightOverride: Boolean = false) {
if (isKeyboardShowing) {
if (keepHeightOverride) {
overrideKeyboardGuidelineWithPreviousHeight()
}
ViewUtil.hideKeyboard(context, imeTarget)
}
}
private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) {

View File

@@ -19,7 +19,6 @@ 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;
@@ -141,7 +140,7 @@ public class InputPanel extends ConstraintLayout
public void onFinishInflate() {
super.onFinishInflate();
View quoteDismiss = findViewById(R.id.quote_dismiss);
View quoteDismiss = findViewById(R.id.quote_dismiss_stub);
this.composeContainer = findViewById(R.id.compose_bubble);
this.stickerSuggestion = findViewById(R.id.input_panel_sticker_suggestion);

View File

@@ -0,0 +1,41 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
import org.signal.core.util.logging.Log
class LoggingAdapterDataObserver(
private val tag: String
) : AdapterDataObserver() {
override fun onChanged() {
Log.d(tag, "onChanged() called")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeChanged() called with: positionStart = $positionStart, itemCount = $itemCount")
}
override fun onItemRangeChanged(positionStart: Int, itemCount: Int, payload: Any?) {
Log.d(tag, "onItemRangeChanged() called with: positionStart = $positionStart, itemCount = $itemCount, payload = $payload")
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeInserted() called with: positionStart = $positionStart, itemCount = $itemCount")
}
override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) {
Log.d(tag, "onItemRangeRemoved() called with: positionStart = $positionStart, itemCount = $itemCount")
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
Log.d(tag, "onItemRangeMoved() called with: fromPosition = $fromPosition, toPosition = $toPosition, itemCount = $itemCount")
}
override fun onStateRestorationPolicyChanged() {
Log.d(tag, "onStateRestorationPolicyChanged() called")
}
}

View File

@@ -0,0 +1,64 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.RequiresApi
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.PowerManagerCompat
@RequiresApi(23)
class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
if (fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
PromptBatterySaverDialogFragment().apply {
arguments = bundleOf()
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
SignalStore.uiHints().lastBatterySaverPrompt = System.currentTimeMillis()
}
}
}
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private val binding by ViewBinderDelegate(PromptBatterySaverBottomSheetBinding::bind)
private lateinit var viewModel: PromptLogsViewModel
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.prompt_battery_saver_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
viewModel = ViewModelProvider(this)[PromptLogsViewModel::class.java]
binding.continueButton.setOnClickListener {
PowerManagerCompat.requestIgnoreBatteryOptimizations(requireContext())
}
binding.dismissButton.setOnClickListener {
SignalStore.uiHints().markDismissedBatterySaverPrompt()
dismiss()
}
}
}

View File

@@ -4,18 +4,16 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.os.Build;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.constraintlayout.widget.ConstraintLayout;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.imageview.ShapeableImageView;
@@ -45,11 +43,12 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.views.Stub;
import java.io.IOException;
import java.util.List;
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
public class QuoteView extends ConstraintLayout implements RecipientForeverObserver {
private static final String TAG = Log.tag(QuoteView.class);
@@ -79,17 +78,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
}
}
private View background;
private ViewGroup mainView;
private ViewGroup footerView;
private TextView authorView;
private EmojiTextView bodyView;
private View quoteBarView;
private ShapeableImageView thumbnailView;
private View attachmentVideoOverlayView;
private ViewGroup attachmentContainerView;
private TextView attachmentNameView;
private ImageView dismissView;
private Stub<View> attachmentVideoOVerlayStub;
private Stub<TextView> attachmentNameViewStub;
private Stub<ImageView> dismissStub;
private EmojiImageView missingStoryReaction;
private EmojiImageView storyReactionEmoji;
@@ -97,7 +92,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
private LiveRecipient author;
private CharSequence body;
private TextView mediaDescriptionText;
private TextView missingLinkText;
private Stub<TextView> missingLinkTextStub;
private SlideDeck attachments;
private MessageType messageType;
private int largeCornerRadius;
@@ -124,32 +119,27 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
initialize(attrs);
}
@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
public QuoteView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize(attrs);
}
private void initialize(@Nullable AttributeSet attrs) {
inflate(getContext(), R.layout.quote_view, this);
inflate(getContext(), R.layout.v2_quote_view, this);
this.background = findViewById(R.id.quote_background);
this.mainView = findViewById(R.id.quote_main);
this.footerView = findViewById(R.id.quote_missing_footer);
this.authorView = findViewById(R.id.quote_author);
this.bodyView = findViewById(R.id.quote_text);
this.quoteBarView = findViewById(R.id.quote_bar);
this.thumbnailView = findViewById(R.id.quote_thumbnail);
this.attachmentVideoOverlayView = findViewById(R.id.quote_video_overlay);
this.attachmentContainerView = findViewById(R.id.quote_attachment_container);
this.attachmentNameView = findViewById(R.id.quote_attachment_name);
this.dismissView = findViewById(R.id.quote_dismiss);
this.mediaDescriptionText = findViewById(R.id.media_type);
this.missingLinkText = findViewById(R.id.quote_missing_text);
this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
this.authorView = findViewById(R.id.quote_author);
this.bodyView = findViewById(R.id.quote_text);
this.quoteBarView = findViewById(R.id.quote_bar);
this.thumbnailView = findViewById(R.id.quote_thumbnail);
this.attachmentVideoOVerlayStub = new Stub<>(findViewById(R.id.quote_video_overlay_stub));
this.attachmentNameViewStub = new Stub<>(findViewById(R.id.quote_attachment_name_stub));
this.dismissStub = new Stub<>(findViewById(R.id.quote_dismiss_stub));
this.mediaDescriptionText = findViewById(R.id.media_type);
this.missingLinkTextStub = new Stub<>(findViewById(R.id.quote_missing_text_stub));
this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
cornerMask = new CornerMask(this);
@@ -159,12 +149,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
messageType = MessageType.fromCode(typedArray.getInt(R.styleable.QuoteView_message_type, 0));
typedArray.recycle();
dismissView.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
dismissStub.setVisibility(messageType == MessageType.PREVIEW ? VISIBLE : GONE);
}
setMessageType(messageType);
dismissView.setOnClickListener(view -> setVisibility(GONE));
}
@Override
@@ -199,7 +187,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
params.width = thumbWidth;
thumbnailView.setLayoutParams(params);
dismissView.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
dismissStub.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
if (dismissStub.resolved()) {
dismissStub.get().setOnClickListener(view -> setVisibility(GONE));
}
}
public void setQuote(GlideRequests glideRequests,
@@ -255,11 +246,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
public void onRecipientChanged(@NonNull Recipient recipient) {
setQuoteAuthor(recipient);
}
public @NonNull Projection getProjection(@NonNull ViewGroup parent) {
return Projection.relativeToParent(parent, this, getCorners());
}
public @NonNull Projection.Corners getCorners() {
return new Projection.Corners(cornerMask.getRadii());
}
@@ -365,13 +351,13 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
// TODO [alex] -- do we need this? mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
thumbnailView.setPadding(0, 0, 0, 0);
StoryTextPostModel model = isStoryReply() ? getStoryTextPost(body) : null;
if (model != null) {
attachmentVideoOverlayView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
thumbnailView.setVisibility(VISIBLE);
glideRequests.load(model)
.centerCrop()
@@ -388,8 +374,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
thumbnailView.setShapeAppearanceModel(buildShapeAppearanceForLayoutDirection());
}
attachmentVideoOverlayView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
thumbnailView.setVisibility(VISIBLE);
glideRequests.load(R.drawable.ic_gift_thumbnail)
.centerCrop()
@@ -403,17 +389,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
attachmentVideoOverlayView.setVisibility(GONE);
attachmentVideoOVerlayStub.setVisibility(GONE);
if (viewOnceSlide != null) {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
attachmentNameViewStub.setVisibility(GONE);
} else if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
thumbnailView.setVisibility(VISIBLE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundResource(R.drawable.dismiss_background);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackgroundResource(R.drawable.dismiss_background);
}
if (imageVideoSlide.hasVideo() && !imageVideoSlide.isVideoGif()) {
attachmentVideoOverlayView.setVisibility(VISIBLE);
attachmentVideoOVerlayStub.setVisibility(VISIBLE);
}
glideRequests.load(new DecryptableUri(imageVideoSlide.getUri()))
.centerCrop()
@@ -422,17 +411,20 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
.into(thumbnailView);
} else if (documentSlide != null){
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(VISIBLE);
attachmentNameView.setText(documentSlide.getFileName().orElse(""));
attachmentNameViewStub.setVisibility(VISIBLE);
attachmentNameViewStub.get().setText(documentSlide.getFileName().orElse(""));
} else {
thumbnailView.setVisibility(GONE);
attachmentContainerView.setVisibility(GONE);
dismissView.setBackgroundDrawable(null);
attachmentNameViewStub.setVisibility(GONE);
if (dismissStub.resolved()) {
dismissStub.get().setBackground(null);
}
}
}
private void setQuoteMissingFooter(boolean missing) {
footerView.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
missingLinkTextStub.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
}
private @Nullable StoryTextPostModel getStoryTextPost(@Nullable CharSequence body) {
@@ -501,12 +493,18 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
QuoteViewColorTheme quoteViewColorTheme = QuoteViewColorTheme.resolveTheme(isOutgoing, isPreview, isWallpaperEnabled);
quoteBarView.setBackgroundColor(quoteViewColorTheme.getBarColor(getContext()));
background.setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
authorView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
bodyView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
attachmentNameView.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
if (attachmentNameViewStub.resolved()) {
attachmentNameViewStub.get().setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
}
mediaDescriptionText.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
missingLinkText.setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
footerView.setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
if (missingLinkTextStub.resolved()) {
missingLinkTextStub.get().setTextColor(quoteViewColorTheme.getForegroundColor(getContext()));
missingLinkTextStub.get().setBackgroundColor(quoteViewColorTheme.getBackgroundColor(getContext()));
}
}
}

View File

@@ -91,7 +91,7 @@ public class ThreadPhotoRailView extends FrameLayout {
public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
ThumbnailView imageView = viewHolder.imageView;
MediaTable.MediaRecord mediaRecord = MediaTable.MediaRecord.from(cursor);
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
Slide slide = MediaUtil.getSlideForAttachment(mediaRecord.getAttachment());
if (slide != null) {
imageView.setImageResource(glideRequests, slide, false, false);

View File

@@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@@ -79,11 +80,12 @@ public class ThumbnailView extends FrameLayout {
private final CornerMask cornerMask;
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
private Stub<TransferControlView> transferControlViewStub;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private Slide slide = null;
private SlideClickListener thumbnailClickListener = null;
private SlidesClickedListener downloadClickListener = null;
private SlideClickListener progressWheelClickListener = null;
private Slide slide = null;
public ThumbnailView(Context context) {
@@ -366,6 +368,11 @@ public class ThumbnailView extends FrameLayout {
transferControlsState = transferControlsState.withSlide(slide)
.withDownloadClickListener(new DownloadClickDispatcher());
if (FeatureFlags.instantVideoPlayback()) {
transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher());
}
transferControlsState.applyState(transferControlViewStub);
} else {
transferControlViewStub.setVisibility(View.GONE);
@@ -518,6 +525,10 @@ public class ThumbnailView extends FrameLayout {
this.downloadClickListener = listener;
}
public void setProgressWheelClickListener(SlideClickListener listener) {
this.progressWheelClickListener = listener;
}
public void clear(GlideRequests glideRequests) {
glideRequests.clear(image);
image.setImageDrawable(null);
@@ -659,6 +670,18 @@ public class ThumbnailView extends FrameLayout {
}
}
private class ProgressWheelClickDispatcher implements View.OnClickListener {
@Override
public void onClick(View view) {
Log.i(TAG, "onClick() for progress wheel");
if (progressWheelClickListener != null && slide != null) {
progressWheelClickListener.onClick(view, slide);
} else {
Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener);
}
}
}
private static class BlurHashClearListener implements ListenableFuture.Listener<Boolean> {
private final GlideRequests glideRequests;

View File

@@ -12,6 +12,7 @@ data class ThumbnailViewTransferControlsState(
val isClickable: Boolean = true,
val slide: Slide? = null,
val downloadClickedListener: OnClickListener? = null,
val progressWheelClickedListener: OnClickListener? = null,
val showDownloadText: Boolean = true
) {
@@ -19,6 +20,7 @@ data class ThumbnailViewTransferControlsState(
fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable)
fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide)
fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener)
fun withProgressWheelClickListener(progressWheelClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(progressWheelClickedListener = progressWheelClickedListener)
fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText)
fun applyState(transferControlView: Stub<TransferControlView>) {
@@ -29,6 +31,7 @@ data class ThumbnailViewTransferControlsState(
transferControlView.get().setSlide(slide)
}
transferControlView.get().setDownloadClickListener(downloadClickedListener)
transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener)
transferControlView.get().setShowDownloadText(showDownloadText)
}
}

View File

@@ -27,9 +27,11 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class TransferControlView extends FrameLayout {
private static final String TAG = "TransferControlView";
private static final int UPLOAD_TASK_WEIGHT = 1;
/**
@@ -125,7 +127,11 @@ public final class TransferControlView extends FrameLayout {
break;
case AttachmentTable.TRANSFER_PROGRESS_PENDING:
case AttachmentTable.TRANSFER_PROGRESS_FAILED:
downloadDetailsText.setText(getDownloadText(this.slides));
String downloadText = getDownloadText(this.slides);
if (!Objects.equals(downloadText, downloadDetailsText.getText().toString())) {
downloadDetailsText.setText(getDownloadText(this.slides));
}
display(downloadDetails);
break;
default:
@@ -152,6 +158,10 @@ public final class TransferControlView extends FrameLayout {
downloadDetails.setOnClickListener(listener);
}
public void setProgressWheelClickListener(final @Nullable OnClickListener listener) {
progressWheel.setOnClickListener(listener);
}
public void clear() {
clearAnimation();
setVisibility(GONE);
@@ -247,13 +257,14 @@ public final class TransferControlView extends FrameLayout {
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventAsync(final PartProgressEvent event) {
if (networkProgress.containsKey(event.attachment)) {
final Attachment attachment = event.attachment;
if (networkProgress.containsKey(attachment)) {
float proportionCompleted = ((float) event.progress) / event.total;
if (event.type == PartProgressEvent.Type.COMPRESSION) {
compresssionProgress.put(event.attachment, proportionCompleted);
compresssionProgress.put(attachment, proportionCompleted);
} else {
networkProgress.put(event.attachment, proportionCompleted);
networkProgress.put(attachment, proportionCompleted);
}
progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress));

View File

@@ -26,7 +26,7 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s
companion object {
@JvmStatic
fun isEligible(): Boolean {
return FeatureFlags.usernames() && SignalStore.phoneNumberPrivacy().isUsernameOutOfSync
return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync
}
}
}

View File

@@ -197,7 +197,7 @@ class SegmentedProgressBar : View, ViewPager.OnPageChangeListener, View.OnTouchL
setLayerType(LAYER_TYPE_SOFTWARE, null)
}
override fun onDraw(canvas: Canvas?) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
segments.forEachIndexed { index, segment ->

View File

@@ -131,7 +131,7 @@ class AppSettingsFragment : DSLSettingsFragment(
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_manageProfileActivity)
},
onQrButtonClicked = {
if (Recipient.self().username.isPresent && Recipient.self().username.get().isNotEmpty()) {
if (SignalStore.account().username != null) {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameLinkSettingsFragment)
} else {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_usernameEducationFragment)

View File

@@ -5,11 +5,10 @@
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.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@@ -33,40 +32,45 @@ class LicenseFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
val textState: State<String> = Single.fromCallable {
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use { it.readText() }
}
val textState: State<List<String>> = Single
.fromCallable {
requireContext().resources.openRawResource(R.raw.third_party_licenses).bufferedReader().use {
it.readText().split("\n")
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeAsState(initial = "")
.subscribeAsState(initial = emptyList())
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))
LicenseScreen(licenseTextLines = 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)
)
fun LicenseScreen(licenseTextLines: List<String>, modifier: Modifier = Modifier) {
Surface(modifier = modifier) {
LazyColumn(modifier = Modifier.padding(horizontal = 4.dp)) {
licenseTextLines.forEach { line ->
item {
Text(
text = line,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
@Preview
@Composable
fun LicenseFragmentPreview() {
LicenseScreen("Lorem ipsum")
LicenseScreen(listOf("Lorem ipsum", "Delor"))
}

View File

@@ -617,10 +617,10 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
switchPref(
title = DSLSettingsText.from("Use V2 ConversationItem"),
isChecked = state.useConversationItemV2,
title = DSLSettingsText.from("Use V2 ConversationItem for Media"),
isChecked = state.useConversationItemV2ForMedia,
onClick = {
viewModel.setUseConversationItemV2(!state.useConversationItemV2)
viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia)
}
)
}

View File

@@ -22,5 +22,5 @@ data class InternalSettingsState(
val disableStorageService: Boolean,
val canClearOnboardingState: Boolean,
val pnpInitialized: Boolean,
val useConversationItemV2: Boolean
val useConversationItemV2ForMedia: Boolean
)

View File

@@ -104,8 +104,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setUseConversationItemV2(enabled: Boolean) {
SignalStore.internalValues().setUseConversationItemV2(enabled)
fun setUseConversationItemV2Media(enabled: Boolean) {
SignalStore.internalValues().setUseConversationItemV2Media(enabled)
refresh()
}
@@ -136,7 +136,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
useConversationItemV2 = SignalStore.internalValues().useConversationItemV2()
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media()
)
fun onClearOnboardingState() {

View File

@@ -22,6 +22,7 @@ import androidx.preference.PreferenceManager
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
@@ -30,8 +31,11 @@ import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.RadioListPreference
import org.thoughtcrime.securesms.components.settings.RadioListPreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.Banner
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.TurnOnNotificationsBottomSheet
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.RingtoneUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
@@ -61,6 +65,11 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
private lateinit var viewModel: NotificationsSettingsViewModel
override fun onResume() {
super.onResume()
viewModel.refresh()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == MESSAGE_SOUND_SELECT && resultCode == Activity.RESULT_OK && data != null) {
val uri: Uri? = data.getParcelableExtraCompat(RingtoneManager.EXTRA_RINGTONE_PICKED_URI, Uri::class.java)
@@ -77,6 +86,8 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
LayoutFactory(::LedColorPreferenceViewHolder, R.layout.dsl_preference_item)
)
Banner.register(adapter)
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
val factory = NotificationsSettingsViewModel.Factory(sharedPreferences)
@@ -89,10 +100,23 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
private fun getConfiguration(state: NotificationsSettingsState): DSLConfiguration {
return configure {
if (!state.messageNotificationsState.canEnableNotifications) {
customPref(
Banner.Model(
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
actionId = R.string.NotificationSettingsFragment__turn_on,
onClick = {
TurnOnNotificationsBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
)
)
}
sectionHeaderPref(R.string.NotificationsSettingsFragment__messages)
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.messageNotificationsState.canEnableNotifications,
isChecked = state.messageNotificationsState.notificationsEnabled,
onClick = {
viewModel.setMessageNotificationsEnabled(!state.messageNotificationsState.notificationsEnabled)
@@ -184,6 +208,16 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
}
)
if (Build.VERSION.SDK_INT >= 23 && state.messageNotificationsState.troubleshootNotifications) {
clickPref(
title = DSLSettingsText.from(R.string.preferences_notifications__troubleshoot),
isEnabled = true,
onClick = {
PromptBatterySaverDialogFragment.show(childFragmentManager)
}
)
}
if (Build.VERSION.SDK_INT < 30) {
if (NotificationChannels.supported()) {
clickPref(
@@ -212,6 +246,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
switchPref(
title = DSLSettingsText.from(R.string.preferences__notifications),
isEnabled = state.callNotificationsState.canEnableNotifications,
isChecked = state.callNotificationsState.notificationsEnabled,
onClick = {
viewModel.setCallNotificationsEnabled(!state.callNotificationsState.notificationsEnabled)

View File

@@ -10,6 +10,7 @@ data class NotificationsSettingsState(
data class MessageNotificationsState(
val notificationsEnabled: Boolean,
val canEnableNotifications: Boolean,
val sound: Uri,
val vibrateEnabled: Boolean,
val ledColor: String,
@@ -17,11 +18,13 @@ data class MessageNotificationsState(
val inChatSoundsEnabled: Boolean,
val repeatAlerts: Int,
val messagePrivacy: String,
val priority: Int
val priority: Int,
val troubleshootNotifications: Boolean
)
data class CallNotificationsState(
val notificationsEnabled: Boolean,
val canEnableNotifications: Boolean,
val ringtone: Uri,
val vibrateEnabled: Boolean
)

View File

@@ -2,101 +2,115 @@ package org.thoughtcrime.securesms.components.settings.app.notifications
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.SlowNotificationHeuristics
import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.Store
class NotificationsSettingsViewModel(private val sharedPreferences: SharedPreferences) : ViewModel() {
private val store = Store(getState())
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
init {
if (NotificationChannels.supported()) {
SignalStore.settings().messageNotificationSound = NotificationChannels.getInstance().messageRingtone
SignalStore.settings().isMessageVibrateEnabled = NotificationChannels.getInstance().messageVibrate
}
store.update { getState(calculateSlowNotifications = true) }
}
private val store = Store(getState())
val state: LiveData<NotificationsSettingsState> = store.stateLiveData
fun refresh() {
store.update { getState(currentState = it) }
}
fun setMessageNotificationsEnabled(enabled: Boolean) {
SignalStore.settings().isMessageNotificationsEnabled = enabled
store.update { getState() }
refresh()
}
fun setMessageNotificationsSound(sound: Uri?) {
val messageSound = sound ?: Uri.EMPTY
SignalStore.settings().messageNotificationSound = messageSound
NotificationChannels.getInstance().updateMessageRingtone(messageSound)
store.update { getState() }
refresh()
}
fun setMessageNotificationVibration(enabled: Boolean) {
SignalStore.settings().isMessageVibrateEnabled = enabled
NotificationChannels.getInstance().updateMessageVibrate(enabled)
store.update { getState() }
refresh()
}
fun setMessageNotificationLedColor(color: String) {
SignalStore.settings().messageLedColor = color
NotificationChannels.getInstance().updateMessagesLedColor(color)
store.update { getState() }
refresh()
}
fun setMessageNotificationLedBlink(blink: String) {
SignalStore.settings().messageLedBlinkPattern = blink
store.update { getState() }
refresh()
}
fun setMessageNotificationInChatSoundsEnabled(enabled: Boolean) {
SignalStore.settings().isMessageNotificationsInChatSoundsEnabled = enabled
store.update { getState() }
refresh()
}
fun setMessageRepeatAlerts(repeats: Int) {
SignalStore.settings().messageNotificationsRepeatAlerts = repeats
store.update { getState() }
refresh()
}
fun setMessageNotificationPrivacy(preference: String) {
SignalStore.settings().messageNotificationsPrivacy = NotificationPrivacyPreference(preference)
store.update { getState() }
refresh()
}
fun setMessageNotificationPriority(priority: Int) {
sharedPreferences.edit().putString(TextSecurePreferences.NOTIFICATION_PRIORITY_PREF, priority.toString()).apply()
store.update { getState() }
refresh()
}
fun setCallNotificationsEnabled(enabled: Boolean) {
SignalStore.settings().isCallNotificationsEnabled = enabled
store.update { getState() }
refresh()
}
fun setCallRingtone(ringtone: Uri?) {
SignalStore.settings().callRingtone = ringtone ?: Uri.EMPTY
store.update { getState() }
refresh()
}
fun setCallVibrateEnabled(enabled: Boolean) {
SignalStore.settings().isCallVibrateEnabled = enabled
store.update { getState() }
refresh()
}
fun setNotifyWhenContactJoinsSignal(enabled: Boolean) {
SignalStore.settings().isNotifyWhenContactJoinsSignal = enabled
store.update { getState() }
refresh()
}
private fun getState(): NotificationsSettingsState = NotificationsSettingsState(
/**
* @param currentState If provided and [calculateSlowNotifications] = false, then we will copy the slow notification state from it
* @param calculateSlowNotifications If true, calculate the true slow notification state (this is not main-thread safe). Otherwise, it will copy from
* [currentState] or default to false.
*/
private fun getState(currentState: NotificationsSettingsState? = null, calculateSlowNotifications: Boolean = false): NotificationsSettingsState = NotificationsSettingsState(
messageNotificationsState = MessageNotificationsState(
notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled,
notificationsEnabled = SignalStore.settings().isMessageNotificationsEnabled && canEnableNotifications(),
canEnableNotifications = canEnableNotifications(),
sound = SignalStore.settings().messageNotificationSound,
vibrateEnabled = SignalStore.settings().isMessageVibrateEnabled,
ledColor = SignalStore.settings().messageLedColor,
@@ -104,16 +118,34 @@ class NotificationsSettingsViewModel(private val sharedPreferences: SharedPrefer
inChatSoundsEnabled = SignalStore.settings().isMessageNotificationsInChatSoundsEnabled,
repeatAlerts = SignalStore.settings().messageNotificationsRepeatAlerts,
messagePrivacy = SignalStore.settings().messageNotificationsPrivacy.toString(),
priority = TextSecurePreferences.getNotificationPriority(ApplicationDependencies.getApplication())
priority = TextSecurePreferences.getNotificationPriority(ApplicationDependencies.getApplication()),
troubleshootNotifications = if (calculateSlowNotifications) {
SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.isHavingDelayedNotifications()
} else if (currentState != null) {
currentState.messageNotificationsState.troubleshootNotifications
} else {
false
}
),
callNotificationsState = CallNotificationsState(
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled,
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled && canEnableNotifications(),
canEnableNotifications = canEnableNotifications(),
ringtone = SignalStore.settings().callRingtone,
vibrateEnabled = SignalStore.settings().isCallVibrateEnabled
),
notifyWhenContactJoinsSignal = SignalStore.settings().isNotifyWhenContactJoinsSignal
)
private fun canEnableNotifications(): Boolean {
val areNotificationsDisabledBySystem = Build.VERSION.SDK_INT >= 26 && (
!NotificationChannels.getInstance().isMessageChannelEnabled ||
!NotificationChannels.getInstance().isMessagesChannelGroupEnabled ||
!NotificationChannels.getInstance().areNotificationsEnabled()
)
return !areNotificationsDisabledBySystem
}
class Factory(private val sharedPreferences: SharedPreferences) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return requireNotNull(modelClass.cast(NotificationsSettingsViewModel(sharedPreferences)))

View File

@@ -60,7 +60,7 @@ private fun DrawScope.drawQr(
deadzonePercent: Float,
logo: ImageBitmap
) {
val deadzonePaddingPercent = 0.07f
val deadzonePaddingPercent = 0.045f
// We want an even number of dots on either side of the deadzone
val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight ->

View File

@@ -1,14 +1,23 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
@@ -20,16 +29,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.compose.getScreenshotBounds
@@ -37,19 +52,25 @@ import org.thoughtcrime.securesms.compose.getScreenshotBounds
* Renders a QR code and username as a badge.
*/
@Composable
fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, username: String, modifier: Modifier = Modifier, screenshotController: ScreenshotController? = null) {
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor)
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor)
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f)
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White)
fun QrCodeBadge(
data: QrCodeState,
colorScheme: UsernameQrCodeColorScheme,
username: String,
modifier: Modifier = Modifier,
screenshotController: ScreenshotController? = null,
usernameCopyable: Boolean = false,
onClick: (() -> Unit) = {}
) {
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border")
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground")
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation")
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor")
var badgeBounds by remember {
mutableStateOf<Rect?>(null)
}
screenshotController?.bind(LocalView.current, badgeBounds)
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 59.dp, vertical = 24.dp)
.onGloballyPositioned {
badgeBounds = it.getScreenshotBounds()
},
@@ -57,24 +78,32 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation.dp
) {
Column {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(296.dp)
) {
Surface(
modifier = Modifier
.padding(
top = 32.dp,
start = 40.dp,
end = 40.dp,
bottom = 16.dp
end = 40.dp
)
.aspectRatio(1f)
.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = Color.White
) {
if (data != null) {
if (data is QrCodeState.Present) {
QrCode(
data = data,
modifier = Modifier.padding(16.dp),
data = data.data,
modifier = Modifier
.border(
width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp,
color = Color(0xFFE9E9E9),
shape = RoundedCornerShape(size = 12.dp)
)
.padding(16.dp),
foregroundColor = foregroundColor,
backgroundColor = Color.White
)
@@ -85,40 +114,169 @@ fun QrCodeBadge(data: QrCodeData?, colorScheme: UsernameQrCodeColorScheme, usern
.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = colorScheme.borderColor,
modifier = Modifier.size(56.dp)
)
if (data is QrCodeState.Loading) {
CircularProgressIndicator(
color = colorScheme.borderColor,
modifier = Modifier.size(56.dp)
)
} else if (data is QrCodeState.NotSet) {
Image(
painter = painterResource(id = R.drawable.symbol_error_circle_24),
contentDescription = stringResource(id = R.string.UsernameLinkSettings_link_not_set_label),
colorFilter = ColorFilter.tint(colorResource(R.color.core_grey_25)),
modifier = Modifier
.width(28.dp)
.height(28.dp)
)
}
}
}
}
Text(
text = username,
color = textColor,
fontSize = 20.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
start = 40.dp,
end = 40.dp,
bottom = 32.dp
start = 32.dp,
end = 32.dp,
top = 8.dp,
bottom = 28.dp
)
)
.clip(RoundedCornerShape(8.dp))
.clickable(
enabled = usernameCopyable,
onClick = onClick
)
.padding(8.dp)
) {
if (usernameCopyable) {
Image(
painter = painterResource(id = R.drawable.symbol_copy_android_24),
contentDescription = null,
colorFilter = if (colorScheme == UsernameQrCodeColorScheme.White) {
ColorFilter.tint(Color.Black)
} else {
ColorFilter.tint(Color.White)
}
)
}
Text(
text = username,
color = textColor,
fontSize = 20.sp,
lineHeight = 26.sp,
fontWeight = FontWeight.W600,
textAlign = TextAlign.Center,
modifier = Modifier.padding(start = 6.dp)
)
}
}
}
}
@Preview
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewWithCode() {
private fun PreviewWithCodeShort() {
SignalTheme {
Surface {
Column {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42",
usernameCopyable = false
)
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42",
usernameCopyable = true
)
}
}
}
}
@Preview(group = "LongName")
@Composable
private fun PreviewWithCodeLong() {
SignalTheme {
Surface {
Column {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "TheAmazingSpiderMan.42",
usernameCopyable = false
)
Spacer(modifier = Modifier.height(8.dp))
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)),
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "TheAmazingSpiderMan.42",
usernameCopyable = true
)
}
}
}
}
@Preview(group = "Colors", heightDp = 1500)
@Composable
private fun PreviewAllColorsP1() {
SignalTheme(isDarkMode = false) {
Surface {
Column {
SampleCode(colorScheme = UsernameQrCodeColorScheme.Blue)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.White)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.Green)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.Grey)
}
}
}
}
@Preview(group = "Colors", heightDp = 1500)
@Composable
private fun PreviewAllColorsP2() {
SignalTheme(isDarkMode = false) {
Surface {
Column {
SampleCode(colorScheme = UsernameQrCodeColorScheme.Pink)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.Orange)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.Purple)
Spacer(modifier = Modifier.height(8.dp))
SampleCode(colorScheme = UsernameQrCodeColorScheme.Tan)
}
}
}
}
@Composable
private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) {
QrCodeBadge(
data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)),
colorScheme = colorScheme,
username = "parker.42"
)
}
@Preview(name = "Light Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "Loading", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewLoading() {
SignalTheme {
Surface {
QrCodeBadge(
data = QrCodeData.forData("https://signal.org", 64),
data = QrCodeState.Loading,
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)
@@ -126,13 +284,14 @@ private fun PreviewWithCode() {
}
}
@Preview
@Preview(name = "Light Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "NotSet", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewWithoutCode() {
SignalTheme(isDarkMode = false) {
private fun PreviewNotSet() {
SignalTheme {
Surface {
QrCodeBadge(
data = null,
data = QrCodeState.NotSet,
colorScheme = UsernameQrCodeColorScheme.Blue,
username = "parker.42"
)

View File

@@ -38,7 +38,7 @@ class QrCodeData(
@WorkerThread
fun forData(data: String, size: Int): QrCodeData {
val qrCodeWriter = QRCodeWriter()
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H.toString())
val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString())
val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints)
val dimens = padded.enclosingRectangle

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks
sealed class QrCodeState {
/** QR code data exists and is available. */
data class Present(val data: QrCodeData) : QrCodeState()
/** QR code data does not exist. */
object NotSet : QrCodeState()
/** QR code data is in an indeterminate loading state. */
object Loading : QrCodeState()
}

View File

@@ -17,7 +17,7 @@ enum class UsernameQrCodeColorScheme(
),
White(
borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF464852),
foregroundColor = Color(0xFF000000),
key = "white"
),
Grey(

View File

@@ -65,12 +65,14 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
.padding(contentPadding)
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceBetween
verticalArrangement = Arrangement.SpaceBetween,
horizontalAlignment = Alignment.CenterHorizontally
) {
QrCodeBadge(
data = state.qrCodeData,
colorScheme = state.selectedColorScheme,
username = state.username
username = state.username,
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp)
)
ColorPicker(
@@ -160,7 +162,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
@Preview
@Composable
private fun ColorPickerItemPreview() {
private fun PreviewColorPickerItem() {
SignalTheme(isDarkMode = false) {
Surface {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -173,7 +175,7 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
@Preview
@Composable
private fun ColorPickerPreview() {
private fun PreviewColorPicker() {
SignalTheme(isDarkMode = false) {
Surface {
ColorPicker(

View File

@@ -1,12 +1,12 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpicker
import kotlinx.collections.immutable.ImmutableList
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
data class UsernameLinkQrColorPickerState(
val username: String,
val qrCodeData: QrCodeData?,
val qrCodeData: QrCodeState,
val colorSchemes: ImmutableList<UsernameQrCodeColorScheme>,
val selectedColorScheme: UsernameQrCodeColorScheme
)

View File

@@ -9,20 +9,22 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkQrColorPickerViewModel : ViewModel() {
private val username: String = Recipient.self().username.get()
private val _state = mutableStateOf(
UsernameLinkQrColorPickerState(
username = username,
qrCodeData = null,
username = SignalStore.account().username!!,
qrCodeData = QrCodeState.Loading,
colorSchemes = UsernameQrCodeColorScheme.values().asList().toImmutableList(),
selectedColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
@@ -33,15 +35,23 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
private val disposable: CompositeDisposable = CompositeDisposable()
init {
disposable += Single
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(username), 64) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
_state.value = _state.value.copy(
qrCodeData = qrData
)
}
val usernameLink = SignalStore.account().usernameLink
if (usernameLink != null) {
disposable += Single
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
_state.value = _state.value.copy(
qrCodeData = QrCodeState.Present(qrData)
)
}
} else {
_state.value = _state.value.copy(
qrCodeData = QrCodeState.NotSet
)
}
}
override fun onCleared() {
@@ -50,6 +60,11 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
fun onColorSelected(color: UsernameQrCodeColorScheme) {
SignalStore.misc().usernameQrCodeColorScheme = color
SignalExecutors.BOUNDED.run {
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
}
_state.value = _state.value.copy(
selectedColorScheme = color
)

View File

@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
sealed class QrScanResult {
class Success(val recipient: Recipient) : QrScanResult()
class NotFound(val username: String) : QrScanResult()
class NotFound(val username: String?) : QrScanResult()
object InvalidData : QrScanResult()

View File

@@ -0,0 +1,20 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
/**
* Result of resetting the username link.
*/
sealed class UsernameLinkResetResult {
/** Successfully reset the username link. */
data class Success(val components: UsernameLinkComponents) : UsernameLinkResetResult()
/** Network failed when making the request. The username is still considered to be "reset". */
object NetworkError : UsernameLinkResetResult()
/** We never made the request because we detected the user had no network. */
object NetworkUnavailable : UsernameLinkResetResult()
/** We never made the request because we hit an unexpected error. */
object UnexpectedError : UsernameLinkResetResult()
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
@@ -17,7 +18,6 @@ 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
@@ -29,6 +29,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -55,7 +56,6 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import java.io.ByteArrayOutputStream
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalPermissionsApi::class
)
class UsernameLinkSettingsFragment : ComposeFragment() {
@@ -71,6 +71,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
val scope: CoroutineScope = rememberCoroutineScope()
val navController: NavController by remember { mutableStateOf(findNavController()) }
var showResetDialog: Boolean by remember { mutableStateOf(false) }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
@@ -95,7 +96,9 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
onShareBadge = {
shareQrBadge(it)
},
screenshotController = screenshotController
screenshotController = screenshotController,
onResetClicked = { showResetDialog = true },
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }
)
}
@@ -114,6 +117,16 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
)
}
}
if (showResetDialog) {
ResetDialog(
onConfirm = {
viewModel.onUsernameLinkReset()
showResetDialog = false
},
onDismiss = { showResetDialog = false }
)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -182,20 +195,43 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
}
}
@Composable
private fun ResetDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
Dialogs.SimpleAlertDialog(
title = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_title),
body = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_body),
confirm = stringResource(id = R.string.UsernameLinkSettings_reset_link_dialog_confirm_button),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = onConfirm,
onDismiss = onDismiss
)
}
@Preview
@Composable
private fun AppBarPreview() {
SignalTheme(isDarkMode = false) {
private fun PreviewAppBar() {
SignalTheme {
Surface {
TopAppBarContent(activeTab = ActiveTab.Code)
}
}
}
@Preview(name = "Light Theme", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PreviewAll() {
FragmentContent()
}
@Preview
@Composable
fun PreviewAll() {
FragmentContent()
private fun PreviewResetDialog() {
SignalTheme {
Surface {
ResetDialog(onConfirm = {}, onDismiss = {})
}
}
}
private fun shareQrBadge(badge: Bitmap) {

View File

@@ -1,6 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
/**
@@ -9,10 +9,11 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.Username
data class UsernameLinkSettingsState(
val activeTab: ActiveTab,
val username: String,
val usernameLink: String,
val qrCodeData: QrCodeData?,
val usernameLinkState: UsernameLinkState,
val qrCodeState: QrCodeState,
val qrCodeColorScheme: UsernameQrCodeColorScheme,
val qrScanResult: QrScanResult? = null,
val usernameLinkResetResult: UsernameLinkResetResult? = null,
val indeterminateProgress: Boolean = false
) {
enum class ActiveTab {

View File

@@ -10,46 +10,46 @@ 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.QrCodeState
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.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import java.io.IOException
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
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,
username = SignalStore.account().username!!,
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
qrCodeState = QrCodeState.Loading,
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
)
val state: State<UsernameLinkSettingsState> = _state
private val disposable: CompositeDisposable = CompositeDisposable()
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
private val usernameRepo: UsernameRepository = UsernameRepository()
init {
disposable += username
disposable += usernameLink
.observeOn(Schedulers.io())
.map { UsernameUtil.generateLink(it) }
.map { link -> link.map { UsernameUtil.generateLink(it) } }
.flatMapSingle { generateQrCodeData(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
_state.value = _state.value.copy(
qrCodeData = qrData
qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet
)
}
}
@@ -70,37 +70,70 @@ class UsernameLinkSettingsViewModel : ViewModel() {
)
}
fun onUsernameLinkReset() {
if (!NetworkUtil.isConnected(ApplicationDependencies.getApplication())) {
_state.value = _state.value.copy(
usernameLinkResetResult = UsernameLinkResetResult.NetworkUnavailable
)
return
}
val currentValue = _state.value
val previousQrValue: QrCodeData? = if (currentValue.qrCodeState is QrCodeState.Present) {
currentValue.qrCodeState.data
} else {
null
}
_state.value = _state.value.copy(
usernameLinkState = UsernameLinkState.Resetting,
qrCodeState = QrCodeState.Loading
)
disposable += usernameRepo.createOrResetUsernameLink()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
val components: Optional<UsernameLinkComponents> = when (result) {
is UsernameLinkResetResult.Success -> Optional.of(result.components)
is UsernameLinkResetResult.NetworkError -> Optional.empty()
else -> { usernameLink.value ?: Optional.empty() }
}
_state.value = _state.value.copy(
usernameLinkState = if (components.isPresent) {
val link = UsernameUtil.generateLink(components.get())
UsernameLinkState.Present(link)
} else {
UsernameLinkState.NotSet
},
usernameLinkResetResult = result,
qrCodeState = if (components.isPresent && previousQrValue != null) {
QrCodeState.Present(previousQrValue)
} else {
QrCodeState.NotSet
}
)
}
}
fun onUsernameLinkResetResultHandled() {
_state.value = _state.value.copy(
usernameLinkResetResult = null
)
}
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
disposable += usernameRepo.convertLinkToUsernameAndAci(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
is UsernameRepository.UsernameLinkConversionResult.Invalid -> QrScanResult.InvalidData
is UsernameRepository.UsernameLinkConversionResult.NotFound -> QrScanResult.NotFound(result.username?.toString())
is UsernameRepository.UsernameLinkConversionResult.NetworkError -> QrScanResult.NetworkError
}
}
.subscribeOn(Schedulers.io())
@@ -119,9 +152,9 @@ class UsernameLinkSettingsViewModel : ViewModel() {
)
}
private fun generateQrCodeData(url: String): Single<QrCodeData> {
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
QrCodeData.forData(url, 64)
url.map { QrCodeData.forData(it, 64) }
}
}
}

View File

@@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -10,6 +12,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.SnackbarHostState
@@ -19,6 +22,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -31,14 +35,15 @@ import androidx.navigation.NavController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.theme.SignalTheme
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.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.util.UsernameUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -48,22 +53,43 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
@Composable
fun UsernameLinkShareScreen(
state: UsernameLinkSettingsState,
onLinkResultHandled: () -> Unit,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavController,
onShareBadge: (Bitmap) -> Unit,
modifier: Modifier = Modifier,
screenshotController: ScreenshotController? = null
screenshotController: ScreenshotController? = null,
onResetClicked: () -> Unit
) {
when (state.usernameLinkResetResult) {
UsernameLinkResetResult.NetworkUnavailable -> {
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled)
}
UsernameLinkResetResult.NetworkError -> {
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_error), onDismiss = onLinkResultHandled)
}
else -> {}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.verticalScroll(rememberScrollState())
) {
val usernameCopiedString = stringResource(id = R.string.UsernameLinkSettings_username_copied_toast)
QrCodeBadge(
data = state.qrCodeData,
data = state.qrCodeState,
colorScheme = state.qrCodeColorScheme,
username = state.username,
screenshotController = screenshotController
screenshotController = screenshotController,
usernameCopyable = true,
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp),
onClick = {
scope.launch {
snackbarHostState.showSnackbar(usernameCopiedString)
}
}
)
ButtonBar(
@@ -76,16 +102,8 @@ fun UsernameLinkShareScreen(
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
)
CopyRow(
displayText = state.username,
copyMessage = stringResource(R.string.UsernameLinkSettings_username_copied_toast),
snackbarHostState = snackbarHostState,
scope = scope
)
CopyRow(
displayText = state.usernameLink,
copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast),
LinkRow(
linkState = state.usernameLinkState,
snackbarHostState = snackbarHostState,
scope = scope
)
@@ -94,7 +112,7 @@ 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(bottom = 19.dp, start = 43.dp, end = 43.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -104,7 +122,7 @@ fun UsernameLinkShareScreen(
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.Center
) {
Buttons.Small(onClick = { /*TODO*/ }) {
Buttons.Small(onClick = onResetClicked) {
Text(
text = stringResource(id = R.string.UsernameLinkSettings_reset_button_label)
)
@@ -133,29 +151,46 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
}
@Composable
private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val context = LocalContext.current
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
Row(
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background)
.clickable {
Util.copyToClipboard(context, displayText)
.padding(
top = 32.dp,
bottom = 24.dp,
start = 24.dp,
end = 24.dp
)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(12.dp)
)
.clickable(enabled = linkState is UsernameLinkState.Present) {
Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link)
scope.launch {
snackbarHostState.showSnackbar(copyMessage)
}
}
.padding(horizontal = 26.dp, vertical = 16.dp)
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
) {
Image(
painter = painterResource(id = R.drawable.symbol_copy_android_24),
painter = painterResource(id = R.drawable.symbol_link_24),
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
Text(
text = displayText,
text = when (linkState) {
is UsernameLinkState.Present -> linkState.link
is UsernameLinkState.NotSet -> stringResource(id = R.string.UsernameLinkSettings_link_not_set_label)
is UsernameLinkState.Resetting -> stringResource(id = R.string.UsernameLinkSettings_resetting_link_label)
},
modifier = Modifier.padding(start = 26.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
@@ -163,45 +198,68 @@ private fun CopyRow(displayText: String, copyMessage: String, snackbarHostState:
}
}
@Preview(name = "Light Theme")
@Composable
private fun ScreenPreviewLightTheme() {
SignalTheme(isDarkMode = false) {
private fun ResetLinkResultDialog(message: String, onDismiss: () -> Unit) {
Dialogs.SimpleMessageDialog(
message = message,
dismiss = stringResource(id = android.R.string.ok),
onDismiss = onDismiss
)
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ScreenPreview() {
SignalTheme {
Surface {
UsernameLinkShareScreen(
state = previewState(),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope(),
navController = NavController(LocalContext.current),
onShareBadge = {}
onShareBadge = {},
onResetClicked = {},
onLinkResultHandled = {}
)
}
}
}
@Preview(name = "Dark Theme")
@Preview(name = "Light Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "LinkRow", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ScreenPreviewDarkTheme() {
SignalTheme(isDarkMode = true) {
private fun LinkRowPreview() {
SignalTheme {
Surface {
UsernameLinkShareScreen(
state = previewState(),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope(),
navController = NavController(LocalContext.current),
onShareBadge = {}
)
Column(modifier = Modifier.padding(8.dp)) {
LinkRow(
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
)
LinkRow(
linkState = UsernameLinkState.NotSet,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
)
LinkRow(
linkState = UsernameLinkState.Resetting,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
)
}
}
}
}
private fun previewState(): UsernameLinkSettingsState {
val link = UsernameUtil.generateLink("maya.45")
val link = "https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"
return UsernameLinkSettingsState(
activeTab = ActiveTab.Code,
username = "maya.45",
usernameLink = link,
qrCodeData = QrCodeData.forData(link, 64),
username = "parker.42",
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
)
}

View File

@@ -0,0 +1,18 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
sealed class UsernameLinkState {
/** Link is set. */
data class Present(val link: String) : UsernameLinkState()
/** Link has not been set yet or otherwise does not exist. */
object NotSet : UsernameLinkState()
/** Link is in the process of being reset. */
object Resetting : UsernameLinkState()
}

View File

@@ -32,6 +32,7 @@ import org.signal.qr.QrScannerView
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist
import org.thoughtcrime.securesms.util.CommunicationActions
import java.util.concurrent.TimeUnit
/**
* A screen that allows you to scan a QR code to start a chat.
@@ -53,7 +54,11 @@ fun UsernameQrScanScreen(
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)
if (qrScanResult.username != null) {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found, qrScanResult.username), onDismiss = onQrResultHandled)
} else {
QrScanResultDialog(stringResource(R.string.UsernameLinkSettings_qr_result_not_found_no_username), onDismiss = onQrResultHandled)
}
}
is QrScanResult.Success -> {
CommunicationActions.startConversation(LocalContext.current, qrScanResult.recipient, null)
@@ -70,7 +75,7 @@ fun UsernameQrScanScreen(
AndroidView(
factory = { context ->
val view = QrScannerView(context)
disposables += view.qrData.distinctUntilChanged().subscribe { data ->
disposables += view.qrData.throttleFirst(3000, TimeUnit.MILLISECONDS).subscribe { data ->
onQrCodeScanned(data)
}
view

View File

@@ -543,14 +543,16 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
}
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
}
)
if (!state.recipient.isReleaseNotes && !state.recipient.isSelf) {
clickPref(
title = DSLSettingsText.from(R.string.ConversationSettingsFragment__view_safety_number),
icon = DSLSettingsIcon.from(R.drawable.ic_safety_number_24),
isEnabled = !state.isDeprecatedOrUnregistered,
onClick = {
VerifyIdentityActivity.startOrShowExchangeMessagesDialog(requireActivity(), recipientState.identityRecord)
}
)
}
}
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
@@ -584,7 +586,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
state.withRecipientSettingsState { recipientSettingsState ->
if (state.recipient.badges.isNotEmpty()) {
if (state.recipient.badges.isNotEmpty() && !state.recipient.isSelf) {
dividerPref()
sectionHeaderPref(R.string.ManageProfileFragment_badges)

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.models
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.databinding.DslBannerBinding
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
/**
* Displays a banner to notify the user of certain state or action that needs to be taken.
*/
object Banner {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, DslBannerBinding::inflate))
}
class Model(
@StringRes val textId: Int,
@StringRes val actionId: Int,
val onClick: () -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return true
}
override fun areContentsTheSame(newItem: Model): Boolean {
return textId == newItem.textId && actionId == newItem.actionId
}
}
private class ViewHolder(binding: DslBannerBinding) : BindingViewHolder<Model, DslBannerBinding>(binding) {
override fun bind(model: Model) {
binding.bannerText.setText(model.textId)
binding.bannerAction.setText(model.actionId)
binding.bannerAction.setOnClickListener { model.onClick() }
}
}
}

View File

@@ -1,9 +1,11 @@
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import com.google.android.exoplayer2.audio.AudioCapabilities
import com.google.android.exoplayer2.audio.AudioSink
import com.google.android.exoplayer2.audio.DefaultAudioSink
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.audio.AudioCapabilities
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import org.signal.core.util.logging.Log
import java.nio.ByteBuffer
@@ -12,6 +14,7 @@ import java.nio.ByteBuffer
* It does eventually recover, but it needs to be given ample opportunity to.
* This class wraps the final DefaultAudioSink to provide exactly that functionality.
*/
@OptIn(UnstableApi::class)
class RetryableInitAudioSink(
context: Context,
enableFloatOutput: Boolean,

View File

@@ -1,565 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.ComponentName;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Objects;
import java.util.Optional;
/**
* Encapsulates control of voice note playback from an Activity component.
* <p>
* This class assumes that it will be created within the scope of Activity#onCreate
* <p>
* The workhorse of this repository is the ProgressEventHandler, which will supply a
* steady stream of update events to the set callback.
*/
public class VoiceNoteMediaController implements DefaultLifecycleObserver {
public static final String EXTRA_THREAD_ID = "voice.note.thread_id";
public static final String EXTRA_MESSAGE_ID = "voice.note.message_id";
public static final String EXTRA_PROGRESS = "voice.note.playhead";
public static final String EXTRA_PLAY_SINGLE = "voice.note.play.single";
private static final String TAG = Log.tag(VoiceNoteMediaController.class);
private MediaBrowserCompat mediaBrowser;
private FragmentActivity activity;
private ProgressEventHandler progressEventHandler;
private MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState = new MutableLiveData<>(VoiceNotePlaybackState.NONE);
private LiveData<Optional<VoiceNotePlayerView.State>> voiceNotePlayerViewState;
private VoiceNoteProximityWakeLockManager voiceNoteProximityWakeLockManager;
private boolean isMediaBrowserCreationPostponed;
private final MediaControllerCompatCallback mediaControllerCompatCallback = new MediaControllerCompatCallback();
public VoiceNoteMediaController(@NonNull FragmentActivity activity) {
this(activity, false);
}
public VoiceNoteMediaController(@NonNull FragmentActivity activity, boolean postponeMediaBrowserCreation) {
this.activity = activity;
this.isMediaBrowserCreationPostponed = postponeMediaBrowserCreation;
activity.getLifecycle().addObserver(this);
voiceNotePlayerViewState = Transformations.switchMap(voiceNotePlaybackState, playbackState -> {
if (playbackState.getClipType() instanceof VoiceNotePlaybackState.ClipType.Message) {
VoiceNotePlaybackState.ClipType.Message message = (VoiceNotePlaybackState.ClipType.Message) playbackState.getClipType();
LiveRecipient sender = Recipient.live(message.getSenderId());
LiveRecipient threadRecipient = Recipient.live(message.getThreadRecipientId());
LiveData<String> name = LiveDataUtil.combineLatest(sender.getLiveDataResolved(),
threadRecipient.getLiveDataResolved(),
(s, t) -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null));
return Transformations.map(name, displayName -> Optional.of(
new VoiceNotePlayerView.State(
playbackState.getUri(),
message.getMessageId(),
message.getThreadId(),
!playbackState.isPlaying(),
message.getSenderId(),
message.getThreadRecipientId(),
message.getMessagePosition(),
message.getTimestamp(),
displayName,
playbackState.getPlayheadPositionMillis(),
playbackState.getTrackDuration(),
playbackState.getSpeed())));
} else {
return new DefaultValueLiveData<>(Optional.empty());
}
});
}
public void ensureMediaBrowser() {
if (mediaBrowser != null) {
return;
}
mediaBrowser = new MediaBrowserCompat(activity,
new ComponentName(activity, VoiceNotePlaybackService.class),
new ConnectionCallback(),
null);
}
public LiveData<VoiceNotePlaybackState> getVoiceNotePlaybackState() {
return voiceNotePlaybackState;
}
public LiveData<Optional<VoiceNotePlayerView.State>> getVoiceNotePlayerViewState() {
return voiceNotePlayerViewState;
}
public void finishPostpone() {
isMediaBrowserCreationPostponed = false;
if (activity != null && mediaBrowser == null && activity.getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) {
ensureMediaBrowser();
mediaBrowser.disconnect();
mediaBrowser.connect();
}
}
@Override
public void onResume(@NonNull LifecycleOwner owner) {
if (mediaBrowser == null && isMediaBrowserCreationPostponed) {
return;
}
ensureMediaBrowser();
mediaBrowser.disconnect();
mediaBrowser.connect();
}
@Override
public void onPause(@NonNull LifecycleOwner owner) {
clearProgressEventHandler();
if (MediaControllerCompat.getMediaController(activity) != null) {
MediaControllerCompat.getMediaController(activity).unregisterCallback(mediaControllerCompatCallback);
}
if (mediaBrowser != null) {
mediaBrowser.disconnect();
}
}
@Override
public void onDestroy(@NonNull LifecycleOwner owner) {
if (voiceNoteProximityWakeLockManager != null) {
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
voiceNoteProximityWakeLockManager = null;
}
activity.getLifecycle().removeObserver(this);
activity = null;
}
private static boolean isPlayerActive(@NonNull PlaybackStateCompat playbackStateCompat) {
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_BUFFERING ||
playbackStateCompat.getState() == PlaybackStateCompat.STATE_PLAYING;
}
private static boolean isPlayerPaused(@NonNull PlaybackStateCompat playbackStateCompat) {
return playbackStateCompat.getState() == PlaybackStateCompat.STATE_PAUSED;
}
private static boolean isPlayerStopped(@NonNull PlaybackStateCompat playbackStateCompat) {
return playbackStateCompat.getState() <= PlaybackStateCompat.STATE_STOPPED;
}
private @Nullable MediaControllerCompat getMediaController() {
if (activity != null) {
return MediaControllerCompat.getMediaController(activity);
} else {
return null;
}
}
public void startConsecutivePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
startPlayback(audioSlideUri, messageId, -1, progress, false);
}
public void startSinglePlayback(@NonNull Uri audioSlideUri, long messageId, double progress) {
startPlayback(audioSlideUri, messageId, -1, progress, true);
}
public void startSinglePlaybackForDraft(@NonNull Uri draftUri, long threadId, double progress) {
startPlayback(draftUri, -1, threadId, progress, true);
}
/**
* Tells the Media service to begin playback of a given audio slide. If the audio
* slide is currently playing, we jump to the desired position and then begin playback.
*
* @param audioSlideUri The Uri of the desired audio slide
* @param messageId The Message id of the given audio slide
* @param progress The desired progress % to seek to.
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
*/
private void startPlayback(@NonNull Uri audioSlideUri, long messageId, long threadId, double progress, boolean singlePlayback) {
if (getMediaController() == null) {
Log.w(TAG, "Called startPlayback before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
getMediaController().getTransportControls().seekTo((long) (duration * progress));
getMediaController().getTransportControls().play();
} else {
Bundle extras = new Bundle();
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_THREAD_ID, threadId);
extras.putDouble(EXTRA_PROGRESS, progress);
extras.putBoolean(EXTRA_PLAY_SINGLE, singlePlayback);
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
}
}
/**
* Tells the Media service to resume playback of a given audio slide. If the audio slide is not
* currently paused, playback will be started from the beginning.
*
* @param audioSlideUri The Uri of the desired audio slide
* @param messageId The Message id of the given audio slide
*/
public void resumePlayback(@NonNull Uri audioSlideUri, long messageId) {
if (getMediaController() == null) {
Log.w(TAG, "Called resumePlayback before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
getMediaController().getTransportControls().play();
} else {
Bundle extras = new Bundle();
extras.putLong(EXTRA_MESSAGE_ID, messageId);
extras.putLong(EXTRA_THREAD_ID, -1L);
extras.putDouble(EXTRA_PROGRESS, 0.0);
extras.putBoolean(EXTRA_PLAY_SINGLE, true);
getMediaController().getTransportControls().playFromUri(audioSlideUri, extras);
}
}
/**
* Pauses playback if the given audio slide is playing.
*
* @param audioSlideUri The Uri of the audio slide to pause.
*/
public void pausePlayback(@NonNull Uri audioSlideUri) {
if (getMediaController() == null) {
Log.w(TAG, "Called pausePlayback(uri) before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
getMediaController().getTransportControls().pause();
}
}
/**
* Pauses playback regardless of which audio slide is playing.
*/
public void pausePlayback() {
if (getMediaController() == null) {
Log.w(TAG, "Called pausePlayback before controller was set. (" + getActivityName() + ")");
return;
}
getMediaController().getTransportControls().pause();
}
/**
* Seeks to a given position if th given audio slide is playing. This call
* is ignored if the given audio slide is not currently playing.
*
* @param audioSlideUri The Uri of the audio slide to seek.
* @param progress The progress percentage to seek to.
*/
public void seekToPosition(@NonNull Uri audioSlideUri, double progress) {
if (getMediaController() == null) {
Log.w(TAG, "Called seekToPosition before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
long duration = getMediaController().getMetadata().getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
getMediaController().getTransportControls().pause();
getMediaController().getTransportControls().seekTo((long) (duration * progress));
getMediaController().getTransportControls().play();
}
}
/**
* Stops playback if the given audio slide is playing
*
* @param audioSlideUri The Uri of the audio slide to stop
*/
public void stopPlaybackAndReset(@NonNull Uri audioSlideUri) {
if (getMediaController() == null) {
Log.w(TAG, "Called stopPlaybackAndReset before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
getMediaController().getTransportControls().stop();
}
}
public void setPlaybackSpeed(@NonNull Uri audioSlideUri, float playbackSpeed) {
if (getMediaController() == null) {
Log.w(TAG, "Called setPlaybackSpeed before controller was set. (" + getActivityName() + ")");
return;
}
if (isCurrentTrack(audioSlideUri)) {
Bundle bundle = new Bundle();
bundle.putFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, playbackSpeed);
getMediaController().sendCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, bundle, null);
}
}
private boolean isCurrentTrack(@NonNull Uri uri) {
if (getMediaController() == null) {
Log.w(TAG, "Called isCurrentTrack before controller was set. (" + getActivityName() + ")");
return false;
}
MediaMetadataCompat metadataCompat = getMediaController().getMetadata();
return metadataCompat != null && Objects.equals(metadataCompat.getDescription().getMediaUri(), uri);
}
private void notifyProgressEventHandler() {
if (getMediaController() == null) {
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (" + getActivityName() + ")");
return;
}
if (progressEventHandler == null && activity != null) {
progressEventHandler = new ProgressEventHandler(getMediaController(), voiceNotePlaybackState);
progressEventHandler.sendEmptyMessage(0);
}
}
private void clearProgressEventHandler() {
if (progressEventHandler != null) {
progressEventHandler = null;
}
}
private @NonNull String getActivityName() {
if (activity == null) {
return "Activity is null";
} else {
return activity.getLocalClassName();
}
}
private final class ConnectionCallback extends MediaBrowserCompat.ConnectionCallback {
@Override
public void onConnected() {
MediaSessionCompat.Token token = mediaBrowser.getSessionToken();
MediaControllerCompat mediaController = new MediaControllerCompat(activity, token);
MediaControllerCompat.setMediaController(activity, mediaController);
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (canExtractPlaybackInformationFromMetadata(mediaMetadataCompat)) {
VoiceNotePlaybackState newState = extractStateFromMetadata(mediaController, mediaMetadataCompat, null);
if (newState != null) {
voiceNotePlaybackState.postValue(newState);
} else {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
}
cleanUpOldProximityWakeLockManager();
voiceNoteProximityWakeLockManager = new VoiceNoteProximityWakeLockManager(activity, mediaController);
mediaController.registerCallback(mediaControllerCompatCallback);
mediaControllerCompatCallback.onPlaybackStateChanged(mediaController.getPlaybackState());
}
@Override
public void onConnectionSuspended() {
Log.d(TAG, "Voice note MediaBrowser connection suspended.");
cleanUpOldProximityWakeLockManager();
}
@Override
public void onConnectionFailed() {
Log.d(TAG, "Voice note MediaBrowser connection failed.");
cleanUpOldProximityWakeLockManager();
}
private void cleanUpOldProximityWakeLockManager() {
if (voiceNoteProximityWakeLockManager != null) {
Log.d(TAG, "Session reconnected, cleaning up old wake lock manager");
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease();
voiceNoteProximityWakeLockManager.unregisterFromLifecycle();
voiceNoteProximityWakeLockManager = null;
}
}
}
private static boolean canExtractPlaybackInformationFromMetadata(@Nullable MediaMetadataCompat mediaMetadataCompat) {
return mediaMetadataCompat != null &&
mediaMetadataCompat.getDescription() != null &&
mediaMetadataCompat.getDescription().getMediaUri() != null;
}
private static @Nullable VoiceNotePlaybackState extractStateFromMetadata(@NonNull MediaControllerCompat mediaController,
@NonNull MediaMetadataCompat mediaMetadataCompat,
@Nullable VoiceNotePlaybackState previousState)
{
Uri mediaUri = Objects.requireNonNull(mediaMetadataCompat.getDescription().getMediaUri());
boolean autoReset = Objects.equals(mediaUri, VoiceNoteMediaItemFactory.NEXT_URI) || Objects.equals(mediaUri, VoiceNoteMediaItemFactory.END_URI);
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
Bundle extras = mediaController.getExtras();
float speed = extras != null ? extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) : 1f;
if (previousState != null && Objects.equals(mediaUri, previousState.getUri())) {
if (position < 0 && previousState.getPlayheadPositionMillis() >= 0) {
position = previousState.getPlayheadPositionMillis();
}
if (duration <= 0 && previousState.getTrackDuration() > 0) {
duration = previousState.getTrackDuration();
}
}
if (duration > 0 && position >= 0 && position <= duration) {
return new VoiceNotePlaybackState(mediaUri,
position,
duration,
autoReset,
speed,
isPlayerActive(mediaController.getPlaybackState()),
getClipType(mediaMetadataCompat.getBundle()));
} else {
return null;
}
}
private static @Nullable VoiceNotePlaybackState constructPlaybackState(@NonNull MediaControllerCompat mediaController,
@Nullable VoiceNotePlaybackState previousState)
{
MediaMetadataCompat mediaMetadataCompat = mediaController.getMetadata();
if (isPlayerActive(mediaController.getPlaybackState()) &&
canExtractPlaybackInformationFromMetadata(mediaMetadataCompat))
{
return extractStateFromMetadata(mediaController, mediaMetadataCompat, previousState);
} else if (isPlayerPaused(mediaController.getPlaybackState()) &&
mediaMetadataCompat != null)
{
long position = mediaController.getPlaybackState().getPosition();
long duration = mediaMetadataCompat.getLong(MediaMetadataCompat.METADATA_KEY_DURATION);
if (previousState != null && position < duration) {
return previousState.asPaused();
} else {
return VoiceNotePlaybackState.NONE;
}
} else {
return VoiceNotePlaybackState.NONE;
}
}
private static @NonNull VoiceNotePlaybackState.ClipType getClipType(@Nullable Bundle mediaExtras) {
long messageId = -1L;
RecipientId senderId = RecipientId.UNKNOWN;
long messagePosition = -1L;
long threadId = -1L;
RecipientId threadRecipientId = RecipientId.UNKNOWN;
long timestamp = -1L;
if (mediaExtras != null) {
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L);
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L);
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L);
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L);
String serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID);
if (serializedSenderId != null) {
senderId = RecipientId.from(serializedSenderId);
}
String serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
if (serializedThreadRecipientId != null) {
threadRecipientId = RecipientId.from(serializedThreadRecipientId);
}
}
if (messageId != -1L) {
return new VoiceNotePlaybackState.ClipType.Message(messageId,
senderId,
threadRecipientId,
messagePosition,
threadId,
timestamp);
} else {
return VoiceNotePlaybackState.ClipType.Draft.INSTANCE;
}
}
private static class ProgressEventHandler extends Handler {
private final MediaControllerCompat mediaController;
private final MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState;
private ProgressEventHandler(@NonNull MediaControllerCompat mediaController,
@NonNull MutableLiveData<VoiceNotePlaybackState> voiceNotePlaybackState)
{
super(Looper.getMainLooper());
this.mediaController = mediaController;
this.voiceNotePlaybackState = voiceNotePlaybackState;
}
@Override
public void handleMessage(@NonNull Message msg) {
VoiceNotePlaybackState newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.getValue());
if (newPlaybackState != null) {
voiceNotePlaybackState.postValue(newPlaybackState);
}
if (isPlayerActive(mediaController.getPlaybackState())) {
sendEmptyMessageDelayed(0, 50);
}
}
}
private final class MediaControllerCompatCallback extends MediaControllerCompat.Callback {
@Override
public void onPlaybackStateChanged(PlaybackStateCompat state) {
if (isPlayerActive(state)) {
notifyProgressEventHandler();
} else {
clearProgressEventHandler();
if (isPlayerStopped(state)) {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE);
}
}
}
}
}

View File

@@ -0,0 +1,453 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.voice
import android.content.ComponentName
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.Message
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import androidx.lifecycle.switchMap
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import java.util.Optional
/**
* This is a lifecycle-aware wrapper for the [MediaController].
* Its main responsibilities are broadcasting playback state through [LiveData],
* and resolving metadata values for a audio clip's URI into a [MediaItem] that media3 can understand.
*/
class VoiceNoteMediaController(val activity: FragmentActivity, private var postponeMediaControllerCreation: Boolean) : DefaultLifecycleObserver {
val voiceNotePlaybackState = MutableLiveData(VoiceNotePlaybackState.NONE)
val voiceNotePlayerViewState: LiveData<Optional<VoiceNotePlayerView.State>>
private val disposables: LifecycleDisposable = LifecycleDisposable()
private var mediaControllerProperty: MediaController? = null
private lateinit var voiceNoteProximityWakeLockManager: VoiceNoteProximityWakeLockManager
private var progressEventHandler: ProgressEventHandler? = null
private var queuedPlayback: PlaybackItem? = null
init {
activity.lifecycle.addObserver(this)
voiceNotePlayerViewState = voiceNotePlaybackState.switchMap { (uri, playheadPositionMillis, trackDuration, _, speed, isPlaying, clipType): VoiceNotePlaybackState ->
if (clipType is VoiceNotePlaybackState.ClipType.Message) {
val (messageId, senderId, threadRecipientId, messagePosition, threadId, timestamp) = clipType
val sender = Recipient.live(senderId)
val threadRecipient = Recipient.live(threadRecipientId)
val name = LiveDataUtil.combineLatest(
sender.liveDataResolved,
threadRecipient.liveDataResolved
) { s: Recipient, t: Recipient -> VoiceNoteMediaItemFactory.getTitle(activity, s, t, null) }
return@switchMap name.map<String, Optional<VoiceNotePlayerView.State>> { displayName: String ->
Optional.of<VoiceNotePlayerView.State>(
VoiceNotePlayerView.State(
uri,
messageId,
threadId,
!isPlaying,
senderId,
threadRecipientId,
messagePosition,
timestamp,
displayName,
playheadPositionMillis,
trackDuration,
speed
)
)
}
} else {
return@switchMap DefaultValueLiveData<Optional<VoiceNotePlayerView.State>>(Optional.empty<VoiceNotePlayerView.State>())
}
}
}
override fun onStart(owner: LifecycleOwner) {
super.onStart(owner)
if (mediaControllerProperty == null && postponeMediaControllerCreation) {
Log.i(TAG, "Postponing media controller creation. (${activity.localClassName}})")
return
}
createMediaControllerAsync()
}
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
progressEventHandler?.sendEmptyMessage(0)
}
override fun onPause(owner: LifecycleOwner) {
clearProgressEventHandler()
super.onPause(owner)
}
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
mediaControllerProperty?.release()
mediaControllerProperty = null
}
override fun onDestroy(owner: LifecycleOwner) {
if (this::voiceNoteProximityWakeLockManager.isInitialized) {
voiceNoteProximityWakeLockManager.unregisterCallbacksAndRelease()
voiceNoteProximityWakeLockManager.unregisterFromLifecycle()
}
activity.lifecycle.removeObserver(this)
super.onDestroy(owner)
}
fun finishPostpone() {
if (mediaControllerProperty == null && postponeMediaControllerCreation && activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
Log.i(TAG, "Finishing postponed media controller creation. (${activity.localClassName}})")
createMediaControllerAsync()
} else {
Log.w(TAG, "Could not finish postponed media controller creation! (${activity.localClassName}})")
}
}
private fun createMediaControllerAsync() {
val applicationContext = activity.applicationContext
val voiceNotePlaybackServiceSessionToken = SessionToken(applicationContext, ComponentName(applicationContext, VoiceNotePlaybackService::class.java))
val mediaControllerBuilder = MediaController.Builder(applicationContext, voiceNotePlaybackServiceSessionToken)
Observable.fromFuture(mediaControllerBuilder.buildAsync())
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe {
initializeMediaController(it)
}
.addTo(disposables)
}
private fun initializeMediaController(uninitializedMediaController: MediaController) {
postponeMediaControllerCreation = false
voiceNoteProximityWakeLockManager = VoiceNoteProximityWakeLockManager(activity, uninitializedMediaController)
uninitializedMediaController.addListener(PlaybackStateListener())
Log.d(TAG, "MediaController successfully initialized. (${activity.localClassName})")
mediaControllerProperty = uninitializedMediaController
queuedPlayback?.let { startPlayback(it) }
queuedPlayback = null
notifyProgressEventHandler()
}
private fun notifyProgressEventHandler() {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Called notifyProgressEventHandler before controller was set. (${activity.localClassName})")
return
}
if (progressEventHandler == null) {
progressEventHandler = ProgressEventHandler(mediaController, voiceNotePlaybackState)
}
progressEventHandler?.sendEmptyMessage(0)
}
private fun clearProgressEventHandler() {
progressEventHandler = null
}
fun startConsecutivePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, false))
}
fun startSinglePlayback(audioSlideUri: Uri, messageId: Long, progress: Double) {
startPlayback(PlaybackItem(audioSlideUri, messageId, -1, progress, true))
}
fun startSinglePlaybackForDraft(draftUri: Uri, threadId: Long, progress: Double) {
startPlayback(PlaybackItem(draftUri, -1, threadId, progress, true))
}
fun resumePlayback(audioSlideUri: Uri, messageId: Long) {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to resume playback before the media controller was ready.")
return
}
if (isCurrentTrack(audioSlideUri)) {
mediaController.play()
} else {
startSinglePlayback(audioSlideUri, messageId, 0.0)
}
}
fun pausePlayback(audioSlideUri: Uri) {
if (isCurrentTrack(audioSlideUri)) {
pausePlayback()
} else {
Log.i(TAG, "Tried to pause $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
}
}
fun pausePlayback() {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to pause playback before the media controller was ready.")
return
}
mediaController.pause()
}
fun seekToPosition(audioSlideUri: Uri, progress: Double) {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to seekToPosition before the media controller was ready.")
return
}
if (isCurrentTrack(audioSlideUri)) {
mediaController.seekTo((mediaController.duration * progress).toLong())
} else {
Log.i(TAG, "Tried to seek $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
}
}
fun stopPlaybackAndReset(audioSlideUri: Uri) {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to stopPlaybackAndReset before the media controller was ready.")
return
}
if (isCurrentTrack(audioSlideUri)) {
mediaController.stop()
} else {
Log.i(TAG, "Tried to stop $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
}
}
fun setPlaybackSpeed(audioSlideUri: Uri, playbackSpeed: Float) {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to set playback speed before the media controller was ready.")
return
}
if (isCurrentTrack(audioSlideUri)) {
mediaController.setPlaybackSpeed(playbackSpeed)
} else {
Log.i(TAG, "Tried to set playback speed of $audioSlideUri but currently playing item is ${getCurrentlyPlayingUri()}")
}
}
/**
* Tells the Media service to begin playback of a given audio slide. If the audio
* slide is currently playing, we jump to the desired position and then begin playback.
*
* @param audioSlideUri The Uri of the desired audio slide
* @param messageId The Message id of the given audio slide
* @param progress The desired progress % to seek to.
* @param singlePlayback The player will only play back the specified Uri, and not build a playlist.
*/
private fun startPlayback(playbackItem: PlaybackItem) {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Tried to start playback before the media controller was ready.")
queuedPlayback = playbackItem
return
}
if (isCurrentTrack(playbackItem.audioSlideUri)) {
val duration: Long = mediaController.duration
mediaController.seekTo((duration * playbackItem.progress).toLong())
mediaController.play()
} else {
val extras = bundleOf(
EXTRA_MESSAGE_ID to playbackItem.messageId,
EXTRA_THREAD_ID to playbackItem.threadId,
EXTRA_PROGRESS to playbackItem.progress,
EXTRA_PLAY_SINGLE to playbackItem.singlePlayback
)
val requestMetadata = RequestMetadata.Builder().setMediaUri(playbackItem.audioSlideUri).setExtras(extras).build()
if (playbackItem.singlePlayback) {
mediaController.clearMediaItems()
}
val mediaItem = MediaItem.Builder()
.setUri(playbackItem.audioSlideUri)
.setRequestMetadata(requestMetadata).build()
mediaController.addMediaItem(mediaItem)
mediaController.play()
}
}
private fun isCurrentTrack(uri: Uri): Boolean {
val mediaController = mediaControllerProperty
if (mediaController == null) {
Log.w(TAG, "Called isCurrentTrack before media controller was set. (${activity.localClassName}})")
return false
}
return uri == getCurrentlyPlayingUri()
}
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
private fun getCurrentlyPlayingUri(): Uri? = mediaControllerProperty?.currentMediaItem?.requestMetadata?.mediaUri
inner class PlaybackStateListener : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (!isActivityResumed()) {
return
}
if (player.isPlaying) {
notifyProgressEventHandler()
} else {
clearProgressEventHandler()
if (player.playbackState == Player.STATE_IDLE || player.playbackState == Player.STATE_ENDED) {
voiceNotePlaybackState.postValue(VoiceNotePlaybackState.NONE)
}
}
}
}
}
private class ProgressEventHandler(
private val mediaController: MediaController,
private val voiceNotePlaybackState: MutableLiveData<VoiceNotePlaybackState>
) : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
val newPlaybackState = constructPlaybackState(mediaController, voiceNotePlaybackState.value)
voiceNotePlaybackState.postValue(newPlaybackState)
val playerActive = mediaController.isPlaying
if (playerActive) {
sendEmptyMessageDelayed(0, 50)
}
}
}
companion object {
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
var EXTRA_THREAD_ID = "voice.note.thread_id"
var EXTRA_MESSAGE_ID = "voice.note.message_id"
var EXTRA_PROGRESS = "voice.note.playhead"
var EXTRA_PLAY_SINGLE = "voice.note.play.single"
@JvmStatic
private fun constructPlaybackState(
mediaController: MediaController,
previousState: VoiceNotePlaybackState?
): VoiceNotePlaybackState {
val mediaUri = mediaController.currentMediaItem?.requestMetadata?.mediaUri
return if (mediaController.isPlaying &&
mediaUri != null
) {
extractStateFromMetadata(mediaController, mediaUri, previousState)
} else if (mediaController.playbackState == Player.STATE_READY && !mediaController.playWhenReady) {
val position = mediaController.currentPosition
val duration = mediaController.contentDuration
if (previousState != null && position < duration) {
previousState.asPaused()
} else {
VoiceNotePlaybackState.NONE
}
} else {
VoiceNotePlaybackState.NONE
}
}
@JvmStatic
private fun extractStateFromMetadata(
mediaController: MediaController,
mediaUri: Uri,
previousState: VoiceNotePlaybackState?
): VoiceNotePlaybackState {
val speed = mediaController.playbackParameters.speed
var duration = mediaController.contentDuration
val mediaMetadata = mediaController.mediaMetadata
var position = mediaController.currentPosition
val autoReset = mediaUri == VoiceNoteMediaItemFactory.NEXT_URI || mediaUri == VoiceNoteMediaItemFactory.END_URI
if (previousState != null && mediaUri == previousState.uri) {
if (position < 0 && previousState.playheadPositionMillis >= 0) {
position = previousState.playheadPositionMillis
}
if (duration <= 0 && previousState.trackDuration > 0) {
duration = previousState.trackDuration
}
}
return if (duration > 0 && position >= 0 && position <= duration) {
VoiceNotePlaybackState(
mediaUri,
position,
duration,
autoReset,
speed,
mediaController.isPlaying,
getClipType(mediaMetadata.extras)
)
} else {
VoiceNotePlaybackState.NONE
}
}
@JvmStatic
private fun getClipType(mediaExtras: Bundle?): VoiceNotePlaybackState.ClipType {
var messageId = -1L
var senderId = RecipientId.UNKNOWN
var messagePosition = -1L
var threadId = -1L
var threadRecipientId = RecipientId.UNKNOWN
var timestamp = -1L
if (mediaExtras != null) {
messageId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID, -1L)
messagePosition = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION, -1L)
threadId = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID, -1L)
timestamp = mediaExtras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_TIMESTAMP, -1L)
val serializedSenderId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_INDIVIDUAL_RECIPIENT_ID)
if (serializedSenderId != null) {
senderId = RecipientId.from(serializedSenderId)
}
val serializedThreadRecipientId = mediaExtras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID)
if (serializedThreadRecipientId != null) {
threadRecipientId = RecipientId.from(serializedThreadRecipientId)
}
}
return if (messageId != -1L) {
VoiceNotePlaybackState.ClipType.Message(
messageId,
senderId!!,
threadRecipientId!!,
messagePosition,
threadId,
timestamp
)
} else {
VoiceNotePlaybackState.ClipType.Draft
}
}
}
/**
* Holder class that contains everything one might need to begin voice note playback. Useful for queueing up items to play when the media controller is being initialized.
*/
data class PlaybackItem(val audioSlideUri: Uri, val messageId: Long, val threadId: Long, val progress: Double, val singlePlayback: Boolean)
}

View File

@@ -3,14 +3,12 @@ package org.thoughtcrime.securesms.components.voice;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.media.MediaDescriptionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.MediaMetadata;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
@@ -144,22 +142,21 @@ class VoiceNoteMediaItemFactory {
}
return new MediaItem.Builder()
.setUri(audioUri)
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build()
)
.setTag(
new MediaDescriptionCompat.Builder()
.setMediaUri(audioUri)
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build())
.build();
.setUri(audioUri)
.setMediaMetadata(
new MediaMetadata.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setExtras(extras)
.build()
)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(audioUri)
.setExtras(extras)
.build()
)
.build();
}
public static @NonNull String getTitle(@NonNull Context context, @NonNull Recipient sender, @NonNull Recipient threadRecipient, @Nullable NotificationPrivacyPreference notificationPrivacyPreference) {
@@ -191,18 +188,16 @@ class VoiceNoteMediaItemFactory {
}
private static MediaItem cloneMediaItem(MediaItem source, String mediaId, Uri uri) {
MediaDescriptionCompat description = source.playbackProperties != null ? (MediaDescriptionCompat) source.playbackProperties.tag : null;
Bundle requestExtras = source.requestMetadata.extras;
return source.buildUpon()
.setMediaId(mediaId)
.setUri(uri)
.setTag(
description != null ?
new MediaDescriptionCompat.Builder()
.setMediaMetadata(source.mediaMetadata)
.setRequestMetadata(
new MediaItem.RequestMetadata.Builder()
.setMediaUri(uri)
.setTitle(description.getTitle())
.setSubtitle(description.getSubtitle())
.setExtras(description.getExtras())
.build() : null)
.setExtras(requestExtras)
.build())
.build();
}
}

View File

@@ -0,0 +1,303 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.voice
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.C
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.Assertions
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import com.google.common.collect.ImmutableList
import org.signal.core.util.PendingIntentFlags.cancelCurrent
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.AvatarUtil
import java.util.Arrays
/**
* This handles all of the notification and playback APIs for playing back a voice note.
* It integrates, using [androidx.media.app.NotificationCompat.MediaStyle], with the system's media controls.
*/
@OptIn(markerClass = [UnstableApi::class])
class VoiceNoteMediaNotificationProvider(val context: Context) : MediaNotification.Provider {
private val notificationChannel: String = NotificationChannels.getInstance().VOICE_NOTES
private var cachedRecipientId: RecipientId? = null
private var cachedBitmap: Bitmap? = null
override fun createNotification(mediaSession: MediaSession, customLayout: ImmutableList<CommandButton>, actionFactory: MediaNotification.ActionFactory, onNotificationChangedCallback: MediaNotification.Provider.Callback): MediaNotification {
val player = mediaSession.player
val builder = NotificationCompat.Builder(context, notificationChannel)
.setSmallIcon(R.drawable.ic_notification)
.setColorized(true)
if (player.isCommandAvailable(Player.COMMAND_GET_METADATA)) {
val metadata: MediaMetadata = player.mediaMetadata
builder
.setContentTitle(metadata.title)
.setContentText(metadata.subtitle)
}
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
val compactViewIndices: IntArray = addNotificationActions(
mediaSession,
getMediaButtons(
player.availableCommands,
customLayout,
player.playWhenReady &&
player.playbackState != Player.STATE_ENDED
),
builder,
actionFactory
)
mediaStyle.setShowActionsInCompactView(*compactViewIndices)
if (player.isCommandAvailable(Player.COMMAND_STOP)) {
mediaStyle.setCancelButtonIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
)
}
val extras = mediaSession.player.mediaMetadata.extras
if (extras != null) {
var color = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR).toInt()
if (color == 0) {
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor()
}
builder.color = color
val pendingIntent = createCurrentContentIntent(extras)
builder.setContentIntent(pendingIntent)
} else {
Log.w(TAG, "Could not populate notification: request metadata extras were null.")
}
builder.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(mediaSession, Player.COMMAND_STOP.toLong())
)
.setOnlyAlertOnce(true)
.setStyle(mediaStyle)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(false)
addLargeIcon(builder, extras, onNotificationChangedCallback)
return MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build())
}
/**
* Borrowed from [DefaultMediaNotificationProvider]
*/
private fun addNotificationActions(
mediaSession: MediaSession?,
mediaButtons: ImmutableList<CommandButton>,
builder: NotificationCompat.Builder,
actionFactory: MediaNotification.ActionFactory
): IntArray {
var compactViewIndices = IntArray(3)
val defaultCompactViewIndices = IntArray(3)
Arrays.fill(compactViewIndices, C.INDEX_UNSET)
Arrays.fill(defaultCompactViewIndices, C.INDEX_UNSET)
var compactViewCommandCount = 0
for (i in mediaButtons.indices) {
val commandButton = mediaButtons[i]
if (commandButton.sessionCommand != null) {
builder.addAction(
actionFactory.createCustomActionFromCustomCommandButton(mediaSession!!, commandButton)
)
} else {
Assertions.checkState(commandButton.playerCommand != Player.COMMAND_INVALID)
builder.addAction(
actionFactory.createMediaAction(
mediaSession!!,
IconCompat.createWithResource(context, commandButton.iconResId),
commandButton.displayName,
commandButton.playerCommand
)
)
}
if (compactViewCommandCount == 3) {
continue
}
val compactViewIndex = commandButton.extras.getInt(
DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX,
C.INDEX_UNSET
)
if (compactViewIndex >= 0 && compactViewIndex < compactViewIndices.size) {
compactViewCommandCount++
compactViewIndices[compactViewIndex] = i
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS ||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM
) {
defaultCompactViewIndices[0] = i
} else if (commandButton.playerCommand == Player.COMMAND_PLAY_PAUSE) {
defaultCompactViewIndices[1] = i
} else if (commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT ||
commandButton.playerCommand == Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM
) {
defaultCompactViewIndices[2] = i
}
}
if (compactViewCommandCount == 0) {
// If there is no custom configuration we use the seekPrev (if any), play/pause (if any),
// seekNext (if any) action in compact view.
var indexInCompactViewIndices = 0
for (i in defaultCompactViewIndices.indices) {
if (defaultCompactViewIndices[i] == C.INDEX_UNSET) {
continue
}
compactViewIndices[indexInCompactViewIndices] = defaultCompactViewIndices[i]
indexInCompactViewIndices++
}
}
for (i in compactViewIndices.indices) {
if (compactViewIndices[i] == C.INDEX_UNSET) {
compactViewIndices = compactViewIndices.copyOf(i)
break
}
}
return compactViewIndices
}
/**
* Borrowed from [DefaultMediaNotificationProvider]
*/
private fun getMediaButtons(
playerCommands: Player.Commands,
customLayout: ImmutableList<CommandButton>,
showPauseButton: Boolean
): ImmutableList<CommandButton> {
val commandButtons = ImmutableList.Builder<CommandButton>()
if (playerCommands.containsAny(Player.COMMAND_SEEK_TO_PREVIOUS, Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)) {
val commandButtonExtras = Bundle()
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
commandButtons.add(
CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.setIconResId(R.drawable.exo_icon_rewind)
.setDisplayName(
context.getString(R.string.media3_controls_seek_to_previous_description)
)
.setExtras(commandButtonExtras)
.build()
)
}
if (playerCommands.contains(Player.COMMAND_PLAY_PAUSE)) {
val commandButtonExtras = Bundle()
commandButtonExtras.putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, C.INDEX_UNSET)
commandButtons.add(
CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_PLAY_PAUSE)
.setIconResId(
if (showPauseButton) R.drawable.exo_notification_pause else R.drawable.exo_notification_play
)
.setExtras(commandButtonExtras)
.setDisplayName(
if (showPauseButton) context.getString(R.string.media3_controls_pause_description) else context.getString(R.string.media3_controls_play_description)
)
.build()
)
}
if (playerCommands.containsAny(Player.COMMAND_STOP)) {
val commandButtonExtras = Bundle()
commandButtons.add(
CommandButton.Builder()
.setPlayerCommand(Player.COMMAND_STOP)
.setIconResId(R.drawable.exo_notification_stop)
.setExtras(commandButtonExtras)
.setDisplayName(context.getString(R.string.media3_controls_seek_to_next_description))
.build()
)
}
for (i in customLayout.indices) {
val button = customLayout[i]
if (button.sessionCommand != null &&
button.sessionCommand!!.commandCode == SessionCommand.COMMAND_CODE_CUSTOM
) {
commandButtons.add(button)
}
}
return commandButtons.build()
}
private fun createCurrentContentIntent(extras: Bundle): PendingIntent? {
val serializedRecipientId = extras.getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID) ?: return null
val recipientId = RecipientId.from(serializedRecipientId)
val startingPosition = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION)
val threadId = extras.getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID)
val conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
.withStartingPosition(startingPosition.toInt())
.build()
conversationActivity.flags = Intent.FLAG_ACTIVITY_NEW_TASK
return PendingIntent.getActivity(
context,
0,
conversationActivity,
cancelCurrent()
)
}
/**
* This will either fetch a cached bitmap and add it to the builder immediately,
* OR it will set a callback to update the notification once the bitmap is fetched by [AvatarUtil]
*/
private fun addLargeIcon(builder: NotificationCompat.Builder, extras: Bundle?, callback: MediaNotification.Provider.Callback) {
if (extras == null || !SignalStore.settings().messageNotificationsPrivacy.isDisplayContact) {
cachedBitmap = null
cachedRecipientId = null
return
}
val serializedRecipientId: String = extras.getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID) ?: return
val currentRecipientId = RecipientId.from(serializedRecipientId)
if (currentRecipientId == cachedRecipientId && cachedBitmap != null) {
builder.setLargeIcon(cachedBitmap)
} else {
cachedRecipientId = currentRecipientId
SignalExecutors.BOUNDED.execute {
try {
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId!!))
builder.setLargeIcon(cachedBitmap)
callback.onNotificationChanged(MediaNotification(NOW_PLAYING_NOTIFICATION_ID, builder.build()))
} catch (e: Exception) {
cachedBitmap = null
}
}
}
}
/**
* We do not currently support any custom commands in the notification area.
*/
override fun handleCustomCommand(session: MediaSession, action: String, extras: Bundle): Boolean {
throw UnsupportedOperationException("Custom command handler for Notification is unused.")
}
companion object {
private const val NOW_PLAYING_NOTIFICATION_ID = 32221
private const val TAG = "VoiceNoteMediaNotificationProvider"
}
}

View File

@@ -1,159 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import org.signal.core.util.PendingIntentFlags;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.AvatarUtil;
import java.util.Objects;
class VoiceNoteNotificationManager {
private static final short NOW_PLAYING_NOTIFICATION_ID = 32221;
private final Context context;
private final MediaControllerCompat controller;
private final PlayerNotificationManager notificationManager;
VoiceNoteNotificationManager(@NonNull Context context,
@NonNull MediaSessionCompat.Token token,
@NonNull PlayerNotificationManager.NotificationListener listener)
{
this.context = context;
controller = new MediaControllerCompat(context, token);
notificationManager = new PlayerNotificationManager.Builder(context, NOW_PLAYING_NOTIFICATION_ID, NotificationChannels.getInstance().VOICE_NOTES)
.setChannelNameResourceId(R.string.NotificationChannel_voice_notes)
.setMediaDescriptionAdapter(new DescriptionAdapter())
.setNotificationListener(listener)
.build();
notificationManager.setMediaSessionToken(token);
notificationManager.setSmallIcon(R.drawable.ic_notification);
notificationManager.setColorized(true);
notificationManager.setUseFastForwardAction(false);
notificationManager.setUseRewindAction(false);
notificationManager.setUseStopAction(true);
}
public void hideNotification() {
notificationManager.setPlayer(null);
}
public void showNotification(@NonNull Player player) {
notificationManager.setPlayer(player);
}
private final class DescriptionAdapter implements PlayerNotificationManager.MediaDescriptionAdapter {
private RecipientId cachedRecipientId;
private Bitmap cachedBitmap;
@Override
public String getCurrentContentTitle(Player player) {
if (hasMetadata()) {
return Objects.toString(controller.getMetadata().getDescription().getTitle(), null);
} else {
return null;
}
}
@Override
public @Nullable PendingIntent createCurrentContentIntent(Player player) {
if (!hasMetadata()) {
return null;
}
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_THREAD_RECIPIENT_ID);
if (serializedRecipientId == null) {
return null;
}
RecipientId recipientId = RecipientId.from(serializedRecipientId);
int startingPosition = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_POSITION);
long threadId = controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_THREAD_ID);
int color = (int) controller.getMetadata().getLong(VoiceNoteMediaItemFactory.EXTRA_COLOR);
if (color == 0) {
color = ChatColorsPalette.UNKNOWN_CONTACT.asSingleColor();
}
notificationManager.setColor(color);
Intent conversationActivity = ConversationIntents.createBuilderSync(context, recipientId, threadId)
.withStartingPosition(startingPosition)
.build();
conversationActivity.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(context,
0,
conversationActivity,
PendingIntentFlags.cancelCurrent());
}
@Override
public String getCurrentContentText(Player player) {
if (hasMetadata()) {
return Objects.toString(controller.getMetadata().getDescription().getSubtitle(), null);
} else {
return null;
}
}
@Override
public @Nullable Bitmap getCurrentLargeIcon(Player player, PlayerNotificationManager.BitmapCallback callback) {
if (!hasMetadata() || !SignalStore.settings().getMessageNotificationsPrivacy().isDisplayContact()) {
cachedBitmap = null;
cachedRecipientId = null;
return null;
}
String serializedRecipientId = controller.getMetadata().getString(VoiceNoteMediaItemFactory.EXTRA_AVATAR_RECIPIENT_ID);
if (serializedRecipientId == null) {
return null;
}
RecipientId currentRecipientId = RecipientId.from(serializedRecipientId);
if (Objects.equals(currentRecipientId, cachedRecipientId) && cachedBitmap != null) {
return cachedBitmap;
} else {
cachedRecipientId = currentRecipientId;
SignalExecutors.BOUNDED.execute(() -> {
try {
cachedBitmap = AvatarUtil.getBitmapForNotification(context, Recipient.resolved(cachedRecipientId));
callback.onBitmap(cachedBitmap);
} catch (Exception e) {
cachedBitmap = null;
}
});
return null;
}
}
private boolean hasMetadata() {
return controller.getMetadata() != null && controller.getMetadata().getDescription() != null;
}
}
}

View File

@@ -1,53 +0,0 @@
package org.thoughtcrime.securesms.components.voice
import android.media.AudioManager
import android.os.Bundle
import android.os.ResultReceiver
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.PlaybackParameters
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.util.Util
import org.signal.core.util.logging.Log
class VoiceNotePlaybackController(
private val player: ExoPlayer,
private val voiceNotePlaybackParameters: VoiceNotePlaybackParameters
) : MediaSessionConnector.CommandReceiver {
companion object {
private val TAG = Log.tag(VoiceNoteMediaController::class.java)
}
override fun onCommand(p: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
Log.d(TAG, "[onCommand] Received player command $command (extras? ${extras != null})")
if (command == VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED) {
val speed = extras?.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f) ?: 1f
player.playbackParameters = PlaybackParameters(speed)
voiceNotePlaybackParameters.setSpeed(speed)
return true
} else if (command == VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM) {
val newStreamType: Int = extras?.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC) ?: AudioManager.STREAM_MUSIC
val currentStreamType = Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
if (newStreamType != currentStreamType) {
val attributes = when (newStreamType) {
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
else -> throw AssertionError()
}
player.playWhenReady = false
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
}
}
return true
}
return false
}
}

View File

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

View File

@@ -1,298 +0,0 @@
package org.thoughtcrime.securesms.components.voice;
import android.content.Context;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.session.PlaybackStateCompat;
import android.widget.Toast;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Stream;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.MediaMetadata;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import org.signal.core.util.concurrent.SimpleTask;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
/**
* ExoPlayer Preparer for Voice Notes. This only supports ACTION_PLAY_FROM_URI
*/
final class VoiceNotePlaybackPreparer implements MediaSessionConnector.PlaybackPreparer {
private static final String TAG = Log.tag(VoiceNotePlaybackPreparer.class);
private static final Executor EXECUTOR = Executors.newSingleThreadExecutor();
private static final long LIMIT = 5;
private final Context context;
private final Player player;
private final VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private boolean canLoadMore;
private Uri latestUri = Uri.EMPTY;
VoiceNotePlaybackPreparer(@NonNull Context context,
@NonNull Player player,
@NonNull VoiceNotePlaybackParameters voiceNotePlaybackParameters)
{
this.context = context;
this.player = player;
this.voiceNotePlaybackParameters = voiceNotePlaybackParameters;
}
@Override
public long getSupportedPrepareActions() {
return PlaybackStateCompat.ACTION_PLAY_FROM_URI;
}
@Override
public void onPrepare(boolean playWhenReady) {
Log.w(TAG, "Requested playback from IDLE state. Ignoring.");
}
@Override
public void onPrepareFromMediaId(@NonNull String mediaId, boolean playWhenReady, @Nullable Bundle extras) {
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromMediaId");
}
@Override
public void onPrepareFromSearch(@NonNull String query, boolean playWhenReady, @Nullable Bundle extras) {
throw new UnsupportedOperationException("VoiceNotePlaybackPreparer does not support onPrepareFromSearch");
}
@Override
public void onPrepareFromUri(@NonNull Uri uri, boolean playWhenReady, @Nullable Bundle extras) {
Log.d(TAG, "onPrepareFromUri: " + uri);
if (extras == null) {
return;
}
long messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID);
long threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID);
double progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0);
boolean singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false);
canLoadMore = false;
latestUri = uri;
SimpleTask.run(EXECUTOR,
() -> {
if (singlePlayback) {
if (messageId != -1) {
return loadMediaItemsForSinglePlayback(messageId);
} else {
return loadMediaItemsForDraftPlayback(threadId, uri);
}
} else {
return loadMediaItemsForConsecutivePlayback(messageId);
}
},
mediaItems -> {
player.clearMediaItems();
if (Util.hasItems(mediaItems) && Objects.equals(latestUri, uri)) {
applyDescriptionsToQueue(mediaItems);
int window = Math.max(0, indexOfPlayerMediaItemByUri(uri));
player.addListener(new Player.Listener() {
@Override
public void onTimelineChanged(@NonNull Timeline timeline, int reason) {
if (timeline.getWindowCount() >= window) {
player.setPlayWhenReady(false);
player.setPlaybackParameters(voiceNotePlaybackParameters.getParameters());
player.seekTo(window, (long) (player.getDuration() * progress));
player.setPlayWhenReady(true);
player.removeListener(this);
}
}
});
player.prepare();
canLoadMore = !singlePlayback;
} else if (Objects.equals(latestUri, uri)) {
Log.w(TAG, "Requested playback but no voice notes could be found.");
ThreadUtil.postToMain(() -> {
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
.show();
});
}
});
}
@MainThread
private void applyDescriptionsToQueue(@NonNull List<MediaItem> mediaItems) {
for (MediaItem mediaItem : mediaItems) {
final MediaItem.LocalConfiguration playbackProperties = mediaItem.playbackProperties;
if (playbackProperties == null) {
continue;
}
int holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri);
MediaItem next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem);
int currentIndex = player.getCurrentWindowIndex();
if (holderIndex != -1) {
if (currentIndex != holderIndex) {
player.removeMediaItem(holderIndex);
player.addMediaItem(holderIndex, mediaItem);
}
if (currentIndex != holderIndex + 1) {
if (player.getMediaItemCount() > 1) {
player.removeMediaItem(holderIndex + 1);
}
player.addMediaItem(holderIndex + 1, next);
}
} else {
int insertLocation = indexAfter(mediaItem);
player.addMediaItem(insertLocation, next);
player.addMediaItem(insertLocation, mediaItem);
}
}
int itemsCount = player.getMediaItemCount();
if (itemsCount > 0) {
int lastIndex = itemsCount - 1;
MediaItem last = player.getMediaItemAt(lastIndex);
if (last.playbackProperties != null &&
Objects.equals(last.playbackProperties.uri, VoiceNoteMediaItemFactory.NEXT_URI))
{
player.removeMediaItem(lastIndex);
if (player.getMediaItemCount() > 1) {
MediaItem end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last);
player.addMediaItem(lastIndex, end);
}
}
}
}
private int indexOfPlayerMediaItemByUri(@NonNull Uri uri) {
for (int i = 0; i < player.getMediaItemCount(); i++) {
final MediaItem.LocalConfiguration playbackProperties = player.getMediaItemAt(i).playbackProperties;
if (playbackProperties != null && playbackProperties.uri.equals(uri)) {
return i;
}
}
return -1;
}
private int indexAfter(@NonNull MediaItem target) {
int size = player.getMediaItemCount();
long targetMessageId = target.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
for (int i = 0; i < size; i++) {
MediaMetadata mediaMetadata = player.getMediaItemAt(i).mediaMetadata;
long messageId = mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
if (messageId > targetMessageId) {
return i;
}
}
return size;
}
public void loadMoreVoiceNotes() {
if (!canLoadMore) {
return;
}
MediaItem currentMediaItem = player.getCurrentMediaItem();
if (currentMediaItem == null) {
return;
}
long messageId = currentMediaItem.mediaMetadata.extras.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID);
SimpleTask.run(EXECUTOR,
() -> loadMediaItemsForConsecutivePlayback(messageId),
mediaItems -> {
if (Util.hasItems(mediaItems) && canLoadMore) {
applyDescriptionsToQueue(mediaItems);
}
});
}
private @NonNull List<MediaItem> loadMediaItemsForSinglePlayback(long messageId) {
try {
MessageRecord messageRecord = SignalDatabase.messages()
.getMessageRecord(messageId);
if (!MessageRecordUtil.hasAudio(messageRecord)) {
Log.w(TAG, "Message does not contain audio.");
return Collections.emptyList();
}
MediaItem mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord);
if (mediaItem == null) {
return Collections.emptyList();
} else {
return Collections.singletonList(mediaItem);
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Could not find message.", e);
return Collections.emptyList();
}
}
private @NonNull List<MediaItem> loadMediaItemsForDraftPlayback(long threadId, @NonNull Uri draftUri) {
return Collections
.singletonList(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri));
}
@WorkerThread
private @NonNull List<MediaItem> loadMediaItemsForConsecutivePlayback(long messageId) {
try {
List<MessageRecord> recordsAfter = SignalDatabase.messages().getMessagesAfterVoiceNoteInclusive(messageId, LIMIT);
return buildFilteredMessageRecordList(recordsAfter).stream()
.map(record -> VoiceNoteMediaItemFactory
.buildMediaItem(context, record))
.filter(Objects::nonNull)
.collect(Collectors.toList());
} catch (NoSuchMessageException e) {
Log.w(TAG, "Could not find message.", e);
return Collections.emptyList();
}
}
private static @NonNull List<MessageRecord> buildFilteredMessageRecordList(@NonNull List<MessageRecord> recordsAfter) {
return Stream.of(recordsAfter)
.takeWhile(MessageRecordUtil::hasAudio)
.toList();
}
@SuppressWarnings("deprecation")
@Override
public boolean onCommand(@NonNull Player player,
@NonNull String command,
@Nullable Bundle extras,
@Nullable ResultReceiver cb)
{
return false;
}
}

View File

@@ -1,31 +1,36 @@
package org.thoughtcrime.securesms.components.voice;
import android.app.Notification;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
import android.os.Process;
import android.support.v4.media.MediaBrowserCompat;
import android.support.v4.media.session.MediaControllerCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media.MediaBrowserServiceCompat;
import androidx.annotation.OptIn;
import androidx.core.content.ContextCompat;
import androidx.media3.common.AudioAttributes;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaController;
import androidx.media3.session.MediaSession;
import androidx.media3.session.MediaSessionService;
import androidx.media3.session.SessionToken;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.ui.PlayerNotificationManager;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
@@ -33,10 +38,8 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.ForegroundServiceUtil;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob;
import org.thoughtcrime.securesms.jobs.UnableToStartException;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.service.KeyCachingService;
@@ -46,95 +49,69 @@ import java.util.List;
/**
* Android Service responsible for playback of voice notes.
*/
public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
@OptIn(markerClass = UnstableApi.class)
public class VoiceNotePlaybackService extends MediaSessionService {
public static final String ACTION_NEXT_PLAYBACK_SPEED = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.next_playback_speed";
public static final String ACTION_SET_AUDIO_STREAM = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService.action.set_audio_stream";
private static final String TAG = Log.tag(VoiceNotePlaybackService.class);
private static final String EMPTY_ROOT_ID = "empty-root-id";
private static final String SESSION_ID = "VoiceNotePlayback";
private static final int LOAD_MORE_THRESHOLD = 2;
private static final long SUPPORTED_ACTIONS = PlaybackStateCompat.ACTION_PLAY |
PlaybackStateCompat.ACTION_PAUSE |
PlaybackStateCompat.ACTION_SEEK_TO |
PlaybackStateCompat.ACTION_STOP |
PlaybackStateCompat.ACTION_PLAY_PAUSE;
private MediaSessionCompat mediaSession;
private MediaSessionConnector mediaSessionConnector;
private VoiceNotePlayer player;
private BecomingNoisyReceiver becomingNoisyReceiver;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNoteNotificationManager voiceNoteNotificationManager;
private VoiceNotePlaybackPreparer voiceNotePlaybackPreparer;
private boolean isForegroundService;
private VoiceNotePlaybackParameters voiceNotePlaybackParameters;
private MediaSession mediaSession;
private VoiceNotePlayer player;
private KeyClearedReceiver keyClearedReceiver;
private VoiceNotePlayerCallback voiceNotePlayerCallback;
@Override
public void onCreate() {
super.onCreate();
mediaSession = new MediaSessionCompat(this, TAG);
voiceNotePlaybackParameters = new VoiceNotePlaybackParameters(mediaSession);
mediaSessionConnector = new MediaSessionConnector(mediaSession);
becomingNoisyReceiver = new BecomingNoisyReceiver(this, mediaSession.getSessionToken());
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getSessionToken());
player = new VoiceNotePlayer(this);
voiceNoteNotificationManager = new VoiceNoteNotificationManager(this,
mediaSession.getSessionToken(),
new VoiceNoteNotificationManagerListener());
voiceNotePlaybackPreparer = new VoiceNotePlaybackPreparer(this, player, voiceNotePlaybackParameters);
player = new VoiceNotePlayer(this);
player.addListener(new VoiceNotePlayerEventListener());
mediaSessionConnector.setPlayer(player);
mediaSessionConnector.setEnabledPlaybackActions(SUPPORTED_ACTIONS);
mediaSessionConnector.setPlaybackPreparer(voiceNotePlaybackPreparer);
mediaSessionConnector.setQueueNavigator(new VoiceNoteQueueNavigator(mediaSession));
voiceNotePlayerCallback = new VoiceNotePlayerCallback(this, player);
mediaSession = buildMediaSession(false);
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
if (mediaSession == null) {
Log.e(TAG, "Unable to create media session at all, stopping service to avoid crash.");
stopSelf();
return;
}
setSessionToken(mediaSession.getSessionToken());
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken());
mediaSession.setActive(true);
keyClearedReceiver.register();
setMediaNotificationProvider(new VoiceNoteMediaNotificationProvider(this));
setListener(new MediaSessionServiceListener());
}
@Override
public void onTaskRemoved(Intent rootIntent) {
super.onTaskRemoved(rootIntent);
player.stop();
player.clearMediaItems();
mediaSession.getPlayer().stop();
mediaSession.getPlayer().clearMediaItems();
}
@Override
public void onDestroy() {
super.onDestroy();
mediaSession.setActive(false);
mediaSession.release();
becomingNoisyReceiver.unregister();
keyClearedReceiver.unregister();
player.release();
mediaSession.release();
mediaSession = null;
clearListener();
mediaSession = null;
super.onDestroy();
keyClearedReceiver.unregister();
}
@Nullable
@Override
public @Nullable BrowserRoot onGetRoot(@NonNull String clientPackageName, int clientUid, @Nullable Bundle rootHints) {
if (clientUid == Process.myUid()) {
return new BrowserRoot(EMPTY_ROOT_ID, null);
} else {
return null;
}
}
@Override
public void onLoadChildren(@NonNull String parentId, @NonNull Result<List<MediaBrowserCompat.MediaItem>> result) {
result.sendResult(Collections.emptyList());
public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) {
return mediaSession;
}
private class VoiceNotePlayerEventListener implements Player.Listener {
private int previousPlaybackState = player.getPlaybackState();
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
@@ -147,30 +124,31 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
private void onPlaybackStateChanged(boolean playWhenReady, int playbackState) {
Log.d(TAG, "playWhenReady: " + playWhenReady + "\nplaybackState: " + playbackState);
switch (playbackState) {
case Player.STATE_BUFFERING:
case Player.STATE_READY:
voiceNoteNotificationManager.showNotification(player);
if (!playWhenReady) {
stopForeground(false);
isForegroundService = false;
becomingNoisyReceiver.unregister();
} else {
sendViewedReceiptForCurrentWindowIndex();
becomingNoisyReceiver.register();
}
break;
case Player.STATE_ENDED:
if (previousPlaybackState == Player.STATE_READY) {
player.clearMediaItems();
}
break;
default:
becomingNoisyReceiver.unregister();
voiceNoteNotificationManager.hideNotification();
}
previousPlaybackState = playbackState;
}
@Override
public void onPositionDiscontinuity(@NonNull Player.PositionInfo oldPosition, @NonNull Player.PositionInfo newPosition, int reason) {
int currentWindowIndex = newPosition.windowIndex;
if (currentWindowIndex == C.INDEX_UNSET) {
int mediaItemIndex = newPosition.mediaItemIndex;
if (mediaItemIndex == C.INDEX_UNSET) {
return;
}
@@ -181,7 +159,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
Log.d(TAG, "onPositionDiscontinuity: current window uri: " + currentMediaItem.playbackProperties.uri);
}
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(currentWindowIndex);
PlaybackParameters playbackParameters = getPlaybackParametersForWindowPosition(mediaItemIndex);
final float speed = playbackParameters != null ? playbackParameters.speed : 1f;
if (speed != player.getPlaybackParameters().speed) {
@@ -189,16 +167,17 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
if (playbackParameters != null) {
player.setPlaybackParameters(playbackParameters);
}
player.seekTo(currentWindowIndex, 1);
player.seekTo(mediaItemIndex, 1);
player.setPlayWhenReady(true);
}
}
boolean isWithinThreshold = currentWindowIndex < LOAD_MORE_THRESHOLD ||
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
boolean isWithinThreshold = mediaItemIndex < LOAD_MORE_THRESHOLD ||
mediaItemIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
if (isWithinThreshold && mediaItemIndex % 2 == 0) {
voiceNotePlayerCallback.loadMoreVoiceNotes();
}
}
@@ -217,13 +196,60 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
mediaSession.setPlaybackToLocal(stream);
}
}
/**
* Some devices, such as the ASUS Zenfone 8, erroneously report multiple broadcast receivers for {@value Intent#ACTION_MEDIA_BUTTON} in the package manager.
* This triggers a failure within the {@link MediaSession} initialization and throws an {@link IllegalStateException}.
* This method will catch that exception and attempt to disable the duplicated broadcast receiver in the hopes of getting the package manager to
* report only 1, avoiding the error.
* If that doesn't work, it returns null, signaling the {@link MediaSession} cannot be built on this device.
*
* @return the built MediaSession, or null if the session cannot be built.
*/
private @Nullable MediaSession buildMediaSession(boolean isRetry) {
try {
return new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build();
} catch (IllegalStateException e) {
if (isRetry) {
Log.e(TAG, "Unable to create media session, even after retry.", e);
return null;
}
Log.w(TAG, "Unable to create media session with default parameters.", e);
PackageManager pm = this.getPackageManager();
Intent queryIntent = new Intent(Intent.ACTION_MEDIA_BUTTON);
queryIntent.setPackage(this.getPackageName());
final List<ResolveInfo> mediaButtonReceivers = pm.queryBroadcastReceivers(queryIntent, /* flags= */ 0);
Log.d(TAG, "Found " + mediaButtonReceivers.size() + " BroadcastReceivers for " + Intent.ACTION_MEDIA_BUTTON);
boolean found = false;
if (mediaButtonReceivers.size() > 1) {
for (ResolveInfo receiverInfo : mediaButtonReceivers) {
final ActivityInfo activityInfo = receiverInfo.activityInfo;
if (!found && activityInfo.packageName.contains("androidx.media.session")) {
found = true;
} else {
pm.setComponentEnabledSetting(new ComponentName(activityInfo.packageName, activityInfo.name), PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP);
}
}
return buildMediaSession(true);
} else {
return null;
}
}
}
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
if (isAudioMessage(currentWindowIndex)) {
return voiceNotePlaybackParameters.getParameters();
return player.getPlaybackParameters();
} else {
return null;
}
@@ -271,49 +297,49 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
}
private class VoiceNoteNotificationManagerListener implements PlayerNotificationManager.NotificationListener {
@Override
public void onNotificationPosted(int notificationId, Notification notification, boolean ongoing) {
if (ongoing && !isForegroundService) {
try {
ForegroundServiceUtil.start(getApplicationContext(), new Intent(getApplicationContext(), VoiceNotePlaybackService.class));
startForeground(notificationId, notification);
isForegroundService = true;
} catch (UnableToStartException e) {
Log.e(TAG, "Unable to start foreground service!", e);
}
}
}
@Override
public void onNotificationCancelled(int notificationId, boolean dismissedByUser) {
stopForeground(true);
isForegroundService = false;
stopSelf();
}
}
/**
* Receiver to stop playback and kill the notification if user locks signal via screen lock.
* This registers itself as a receiver on the [Context] as soon as it can.
*/
private static class KeyClearedReceiver extends BroadcastReceiver {
private static final String TAG = Log.tag(KeyClearedReceiver.class);
private static final IntentFilter KEY_CLEARED_FILTER = new IntentFilter(KeyCachingService.CLEAR_KEY_EVENT);
private final Context context;
private final MediaControllerCompat controller;
private final Context context;
private final ListenableFuture<MediaController> controllerFuture;
private MediaController controller;
private boolean registered;
private KeyClearedReceiver(@NonNull Context context, @NonNull MediaSessionCompat.Token token) {
this.context = context;
this.controller = new MediaControllerCompat(context, token);
private KeyClearedReceiver(@NonNull Context context, @NonNull SessionToken token) {
this.context = context;
Log.d(TAG, "Creating media controller");
controllerFuture = new MediaController.Builder(context, token).buildAsync();
Futures.addCallback(controllerFuture, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable MediaController result) {
Log.d(TAG, "Successfully created media controller.");
controller = result;
register();
}
@Override
public void onFailure(@NonNull Throwable t) {
Log.w(TAG, "KeyClearedReceiver.onFailure", t);
}
}, ContextCompat.getMainExecutor(context));
}
void register() {
if (controller == null) {
Log.w(TAG, "Failed to register KeyClearedReceiver because MediaController was null.");
return;
}
if (!registered) {
context.registerReceiver(this, KEY_CLEARED_FILTER);
ContextCompat.registerReceiver(context, this, KEY_CLEARED_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED);
registered = true;
Log.d(TAG, "Successfully registered.");
}
}
@@ -322,48 +348,24 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
context.unregisterReceiver(this);
registered = false;
}
MediaController.releaseFuture(controllerFuture);
}
@Override
public void onReceive(Context context, Intent intent) {
controller.getTransportControls().stop();
if (controller == null) {
Log.w(TAG, "Received broadcast but could not stop playback because MediaController was null.");
} else {
Log.i(TAG, "Received broadcast, stopping playback.");
controller.stop();
}
}
}
/**
* Receiver to pause playback when things become noisy.
*/
private static class BecomingNoisyReceiver extends BroadcastReceiver {
private static final IntentFilter NOISY_INTENT_FILTER = new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY);
private final Context context;
private final MediaControllerCompat controller;
private boolean registered;
private BecomingNoisyReceiver(Context context, MediaSessionCompat.Token token) {
this.context = context;
this.controller = new MediaControllerCompat(context, token);
}
void register() {
if (!registered) {
context.registerReceiver(this, NOISY_INTENT_FILTER);
registered = true;
}
}
void unregister() {
if (registered) {
context.unregisterReceiver(this);
registered = false;
}
}
public void onReceive(Context context, @NonNull Intent intent) {
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
controller.getTransportControls().pause();
}
private static class MediaSessionServiceListener implements Listener {
@Override
public void onForegroundServiceStartNotAllowedException() {
Log.e(TAG, "Could not start VoiceNotePlaybackService, encountered a ForegroundServiceStartNotAllowedException.");
}
}
}

View File

@@ -1,29 +1,47 @@
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.DefaultLoadControl
import com.google.android.exoplayer2.DefaultRenderersFactory
import com.google.android.exoplayer2.ExoPlayer
import com.google.android.exoplayer2.ForwardingPlayer
import com.google.android.exoplayer2.audio.AudioAttributes
import com.google.android.exoplayer2.audio.AudioSink
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
/**
* A lightweight wrapper around ExoPlayer that compartmentalizes some logic and adds a few functions, most importantly the seek behavior.
*
* @param context
*/
@OptIn(UnstableApi::class)
class VoiceNotePlayer @JvmOverloads constructor(
context: Context,
val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
private val internalPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setRenderersFactory(WorkaroundRenderersFactory(context))
.setMediaSourceFactory(SignalMediaSourceFactory(context))
.setLoadControl(
DefaultLoadControl.Builder()
.setBufferDurationsMs(Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)
.build()
).build().apply {
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
}
)
.setHandleAudioBecomingNoisy(true).build()
) : ForwardingPlayer(internalPlayer) {
init {
setAudioAttributes(AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build(), true)
}
/**
* Required to expose this because this is unique to [ExoPlayer], not the generic [androidx.media3.common.Player] interface.
*/
fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {
internalPlayer.setAudioAttributes(audioAttributes, handleAudioFocus)
}
override fun seekTo(windowIndex: Int, positionMs: Long) {
super.seekTo(windowIndex, positionMs)
@@ -46,6 +64,7 @@ class VoiceNotePlayer @JvmOverloads constructor(
/**
* @see RetryableInitAudioSink
*/
@OptIn(androidx.media3.common.util.UnstableApi::class)
class WorkaroundRenderersFactory(val context: Context) : DefaultRenderersFactory(context) {
override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean): AudioSink? {
return RetryableInitAudioSink(context, enableFloatOutput, enableAudioTrackPlaybackParams, enableOffload)

View File

@@ -0,0 +1,269 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.voice
import android.content.Context
import android.media.AudioManager
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.annotation.MainThread
import androidx.annotation.OptIn
import androidx.annotation.WorkerThread
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.LocalConfiguration
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionCommands
import androidx.media3.session.SessionResult
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.NoSuchMessageException
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.util.hasAudio
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlin.math.max
/**
* See [VoiceNotePlaybackService].
*/
@OptIn(UnstableApi::class)
class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer) : MediaSession.Callback {
companion object {
private val SUPPORTED_ACTIONS = Player.Commands.Builder()
.addAll(
Player.COMMAND_PLAY_PAUSE,
Player.COMMAND_PREPARE,
Player.COMMAND_STOP,
Player.COMMAND_SEEK_TO_DEFAULT_POSITION,
Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_MEDIA_ITEM,
Player.COMMAND_SEEK_BACK,
Player.COMMAND_SEEK_FORWARD,
Player.COMMAND_SET_SPEED_AND_PITCH,
Player.COMMAND_SET_REPEAT_MODE,
Player.COMMAND_GET_CURRENT_MEDIA_ITEM,
Player.COMMAND_GET_TIMELINE,
Player.COMMAND_GET_METADATA,
Player.COMMAND_SET_PLAYLIST_METADATA,
Player.COMMAND_SET_MEDIA_ITEM,
Player.COMMAND_CHANGE_MEDIA_ITEMS,
Player.COMMAND_GET_AUDIO_ATTRIBUTES,
Player.COMMAND_GET_TEXT,
Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS,
Player.COMMAND_RELEASE
)
.build()
private val CUSTOM_COMMANDS = SessionCommands.Builder()
.add(SessionCommand(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, Bundle.EMPTY))
.add(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY))
.build()
private const val DEFAULT_PLAYBACK_SPEED = 1f
private const val LIMIT: Long = 5
}
private val TAG = Log.tag(VoiceNotePlayerCallback::class.java)
private val EXECUTOR: Executor = Executors.newSingleThreadExecutor()
private val customLayout: List<CommandButton> = mutableListOf<CommandButton>().apply {
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_PLAY_PAUSE).build())
add(CommandButton.Builder().setPlayerCommand(Player.COMMAND_STOP).build())
}
private var canLoadMore = false
private var latestUri = Uri.EMPTY
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
return MediaSession.ConnectionResult.accept(CUSTOM_COMMANDS, SUPPORTED_ACTIONS)
}
override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) {
if (customLayout.isNotEmpty() && controller.controllerVersion != 0) {
session.setCustomLayout(controller, customLayout)
}
}
override fun onAddMediaItems(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, mediaItems: MutableList<MediaItem>): ListenableFuture<MutableList<MediaItem>> {
mediaItems.forEach {
val uri = it.localConfiguration?.uri
if (uri != null) {
val extras = it.requestMetadata.extras
onPrepareFromUri(uri, extras)
} else {
throw UnsupportedOperationException("VoiceNotePlayerCallback does not support onPrepareFromMediaId/onPrepareFromSearch")
}
}
return super.onAddMediaItems(mediaSession, controller, mediaItems)
}
private fun onPrepareFromUri(uri: Uri, extras: Bundle?) {
Log.d(TAG, "onPrepareFromUri: $uri")
if (extras == null) {
return
}
val messageId = extras.getLong(VoiceNoteMediaController.EXTRA_MESSAGE_ID)
val threadId = extras.getLong(VoiceNoteMediaController.EXTRA_THREAD_ID)
val progress = extras.getDouble(VoiceNoteMediaController.EXTRA_PROGRESS, 0.0)
val singlePlayback = extras.getBoolean(VoiceNoteMediaController.EXTRA_PLAY_SINGLE, false)
canLoadMore = false
latestUri = uri
SimpleTask.run(
EXECUTOR,
{
if (singlePlayback) {
if (messageId != -1L) {
return@run loadMediaItemsForSinglePlayback(messageId)
} else {
return@run loadMediaItemsForDraftPlayback(threadId, uri)
}
} else {
return@run loadMediaItemsForConsecutivePlayback(messageId)
}
}
) { mediaItems: List<MediaItem> ->
player.clearMediaItems()
if (mediaItems.isNotEmpty() && latestUri == uri) {
addItemsToPlaylist(mediaItems)
val window = max(0, indexOfPlayerMediaItemByUri(uri))
player.addListener(object : Player.Listener {
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (timeline.windowCount >= window) {
player.playWhenReady = false
player.playbackParameters = PlaybackParameters(DEFAULT_PLAYBACK_SPEED)
player.seekTo(window, (player.duration * progress).toLong())
player.playWhenReady = true
player.removeListener(this)
}
}
})
player.prepare()
canLoadMore = !singlePlayback
} else if (latestUri == uri) {
Log.w(TAG, "Requested playback but no voice notes could be found.")
ThreadUtil.postToMain {
Toast.makeText(context, R.string.VoiceNotePlaybackPreparer__failed_to_play_voice_message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> {
return when (customCommand.customAction) {
VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED -> incrementPlaybackSpeed(args)
VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM -> setAudioStream(args)
else -> super.onCustomCommand(session, controller, customCommand, args)
}
}
private fun incrementPlaybackSpeed(extras: Bundle): ListenableFuture<SessionResult> {
val speed = extras.getFloat(VoiceNotePlaybackService.ACTION_NEXT_PLAYBACK_SPEED, 1f)
player.playbackParameters = PlaybackParameters(speed)
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
private fun setAudioStream(extras: Bundle): ListenableFuture<SessionResult> {
val newStreamType: Int = extras.getInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, AudioManager.STREAM_MUSIC)
val currentStreamType = androidx.media3.common.util.Util.getStreamTypeForAudioUsage(player.audioAttributes.usage)
if (newStreamType != currentStreamType) {
val attributes = when (newStreamType) {
AudioManager.STREAM_MUSIC -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MUSIC).setUsage(C.USAGE_MEDIA).build()
AudioManager.STREAM_VOICE_CALL -> AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).setUsage(C.USAGE_VOICE_COMMUNICATION).build()
else -> throw AssertionError()
}
player.playWhenReady = false
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
@MainThread
private fun addItemsToPlaylist(mediaItems: List<MediaItem>) {
var mediaItemsWithNextTone = mediaItems.flatMap { listOf(it, VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(it)) }.toMutableList()
mediaItemsWithNextTone = mediaItemsWithNextTone.subList(0, mediaItemsWithNextTone.size - 1).toMutableList()
if (player.mediaItemCount == 0) {
mediaItemsWithNextTone += VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(mediaItemsWithNextTone.last())
player.addMediaItems(mediaItemsWithNextTone)
} else {
player.addMediaItems(player.mediaItemCount, mediaItemsWithNextTone)
}
}
private fun indexOfPlayerMediaItemByUri(uri: Uri): Int {
for (i in 0 until player.mediaItemCount) {
val playbackProperties: LocalConfiguration? = player.getMediaItemAt(i).playbackProperties
if (playbackProperties?.uri == uri) {
return i
}
}
return -1
}
fun loadMoreVoiceNotes() {
if (!canLoadMore) {
return
}
val currentMediaItem: MediaItem = player.currentMediaItem ?: return
val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
val currentPlaylist = List(player.mediaItemCount) { index -> player.getMediaItemAt(index) }.mapNotNull { it.requestMetadata.mediaUri }
SimpleTask.run(
EXECUTOR,
{ loadMediaItemsForConsecutivePlayback(messageId).filterNot { it.requestMetadata.mediaUri in currentPlaylist } }
) { mediaItems: List<MediaItem> ->
if (mediaItems.isNotEmpty() && canLoadMore) {
addItemsToPlaylist(mediaItems)
}
}
}
private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List<MediaItem> {
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
}
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
return try {
listOf(messages.getMessageRecord(messageId)).messageRecordsToVoiceNoteMediaItems()
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Could not find message.", e)
emptyList()
}
}
@WorkerThread
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
return try {
messages.getMessagesAfterVoiceNoteInclusive(messageId, LIMIT).messageRecordsToVoiceNoteMediaItems()
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Could not find message.", e)
emptyList()
}
}
private fun List<MessageRecord>.messageRecordsToVoiceNoteMediaItems(): List<MediaItem> {
return this.filter { it.hasAudio() }.mapNotNull { VoiceNoteMediaItemFactory.buildMediaItem(context, it) }
}
}

View File

@@ -7,12 +7,13 @@ import android.hardware.SensorManager
import android.media.AudioManager
import android.os.Bundle
import android.os.PowerManager
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.util.ServiceUtil
import java.util.concurrent.TimeUnit
@@ -25,7 +26,7 @@ private const val PROXIMITY_THRESHOLD = 5f
*/
class VoiceNoteProximityWakeLockManager(
private val activity: FragmentActivity,
private val mediaController: MediaControllerCompat
private val mediaController: MediaController
) : DefaultLifecycleObserver {
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
@@ -33,7 +34,7 @@ class VoiceNoteProximityWakeLockManager(
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
private val mediaControllerCallback = MediaControllerCallback()
private val mediaControllerCallback = ProximityListener()
private val hardwareSensorEventListener = HardwareSensorEventListener()
private var startTime: Long = -1
@@ -46,7 +47,7 @@ class VoiceNoteProximityWakeLockManager(
override fun onResume(owner: LifecycleOwner) {
if (proximitySensor != null) {
mediaController.registerCallback(mediaControllerCallback)
mediaController.addListener(mediaControllerCallback)
}
}
@@ -57,7 +58,7 @@ class VoiceNoteProximityWakeLockManager(
}
fun unregisterCallbacksAndRelease() {
mediaController.unregisterCallback(mediaControllerCallback)
mediaController.addListener(mediaControllerCallback)
cleanUpWakeLock()
}
@@ -69,9 +70,6 @@ class VoiceNoteProximityWakeLockManager(
private fun isActivityResumed() = activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
private fun isPlayerActive() = mediaController.playbackState.state == PlaybackStateCompat.STATE_BUFFERING ||
mediaController.playbackState.state == PlaybackStateCompat.STATE_PLAYING
private fun cleanUpWakeLock() {
startTime = -1L
sensorManager.unregisterListener(hardwareSensorEventListener)
@@ -87,26 +85,29 @@ class VoiceNoteProximityWakeLockManager(
private fun sendNewStreamTypeToPlayer(newStreamType: Int) {
val params = Bundle()
params.putInt(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, newStreamType)
mediaController.sendCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, params, null)
mediaController.sendCustomCommand(SessionCommand(VoiceNotePlaybackService.ACTION_SET_AUDIO_STREAM, Bundle.EMPTY), params)
}
inner class MediaControllerCallback : MediaControllerCompat.Callback() {
override fun onPlaybackStateChanged(state: PlaybackStateCompat) {
if (!isActivityResumed()) {
return
}
if (isPlayerActive()) {
if (startTime == -1L) {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
inner class ProximityListener : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (!isActivityResumed()) {
return
}
if (player.isPlaying) {
if (startTime == -1L) {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
}
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
cleanUpWakeLock()
}
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became inactive. Cleaning up wake lock.")
cleanUpWakeLock()
}
}
}
@@ -116,7 +117,7 @@ class VoiceNoteProximityWakeLockManager(
if (startTime == -1L ||
System.currentTimeMillis() - startTime <= 500 ||
!isActivityResumed() ||
!isPlayerActive() ||
!mediaController.isPlaying ||
event.sensor.type != Sensor.TYPE_PROXIMITY
) {
return

View File

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

View File

@@ -1,371 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ArgbEvaluator;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.graphics.Color;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityManager;
import android.view.animation.AccelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.AccessibilityUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
public final class WebRtcAnswerDeclineButton extends LinearLayout implements AccessibilityManager.TouchExplorationStateChangeListener {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(WebRtcAnswerDeclineButton.class);
private static final int TOTAL_TIME = 1000;
private static final int SHAKE_TIME = 200;
private static final int UP_TIME = (TOTAL_TIME - SHAKE_TIME) / 2;
private static final int DOWN_TIME = (TOTAL_TIME - SHAKE_TIME) / 2;
private static final int FADE_OUT_TIME = 300;
private static final int FADE_IN_TIME = 100;
private static final int SHIMMER_TOTAL = UP_TIME + SHAKE_TIME;
private static final int ANSWER_THRESHOLD = 112;
private static final int DECLINE_THRESHOLD = 56;
private AnswerDeclineListener listener;
@Nullable private DragToAnswer dragToAnswerListener;
private AccessibilityManager accessibilityManager;
private boolean ringAnimation;
public WebRtcAnswerDeclineButton(Context context) {
super(context);
initialize();
}
public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
initialize();
}
public WebRtcAnswerDeclineButton(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
private void initialize() {
setOrientation(LinearLayout.VERTICAL);
setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
accessibilityManager = ServiceUtil.getAccessibilityManager(getContext());
createView(accessibilityManager.isTouchExplorationEnabled());
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
accessibilityManager.addTouchExplorationStateChangeListener(this);
}
@Override
protected void onDetachedFromWindow() {
accessibilityManager.removeTouchExplorationStateChangeListener(this);
super.onDetachedFromWindow();
}
private void createView(boolean isTouchExplorationEnabled) {
if (isTouchExplorationEnabled) {
inflate(getContext(), R.layout.webrtc_answer_decline_button_accessible, this);
findViewById(R.id.answer).setOnClickListener((view) -> listener.onAnswered());
findViewById(R.id.reject).setOnClickListener((view) -> listener.onDeclined());
} else {
inflate(getContext(), R.layout.webrtc_answer_decline_button, this);
ImageView answer = findViewById(R.id.answer);
dragToAnswerListener = new DragToAnswer(answer, this);
answer.setOnTouchListener(dragToAnswerListener);
if (ringAnimation) {
startRingingAnimation();
}
}
}
public void setAnswerDeclineListener(AnswerDeclineListener listener) {
this.listener = listener;
}
public void startRingingAnimation() {
ringAnimation = true;
if (dragToAnswerListener != null) {
dragToAnswerListener.startRingingAnimation();
}
}
public void stopRingingAnimation() {
ringAnimation = false;
if (dragToAnswerListener != null) {
dragToAnswerListener.stopRingingAnimation();
}
}
@Override
public void onTouchExplorationStateChanged(boolean enabled) {
removeAllViews();
createView(enabled);
}
private class DragToAnswer implements View.OnTouchListener {
private final TextView swipeUpText;
private final ImageView answer;
private final TextView swipeDownText;
private final ImageView arrowOne;
private final ImageView arrowTwo;
private final ImageView arrowThree;
private final ImageView arrowFour;
private float lastY;
private boolean animating = false;
private boolean complete = false;
private AnimatorSet animatorSet;
private DragToAnswer(@NonNull ImageView answer, WebRtcAnswerDeclineButton view) {
this.swipeUpText = view.findViewById(R.id.swipe_up_text);
this.answer = answer;
this.swipeDownText = view.findViewById(R.id.swipe_down_text);
this.arrowOne = view.findViewById(R.id.arrow_one);
this.arrowTwo = view.findViewById(R.id.arrow_two);
this.arrowThree = view.findViewById(R.id.arrow_three);
this.arrowFour = view.findViewById(R.id.arrow_four);
}
private void startRingingAnimation() {
if (!animating) {
animating = true;
animateElements(0);
}
}
private void stopRingingAnimation() {
if (animating) {
animating = false;
resetElements();
}
}
@Override
public boolean onTouch(View v, MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
resetElements();
swipeUpText.animate().alpha(0).setDuration(200).start();
swipeDownText.animate().alpha(0).setDuration(200).start();
lastY = event.getRawY();
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
swipeUpText.clearAnimation();
swipeDownText.clearAnimation();
swipeUpText.setAlpha(1);
swipeDownText.setAlpha(1);
answer.setRotation(0);
answer.getDrawable().setTint(getResources().getColor(R.color.green_600));
answer.getBackground().setTint(Color.WHITE);
animating = true;
animateElements(0);
break;
case MotionEvent.ACTION_MOVE:
float difference = event.getRawY() - lastY;
float differenceThreshold;
float percentageToThreshold;
int backgroundColor;
int foregroundColor;
if (difference <= 0) {
differenceThreshold = ViewUtil.dpToPx(getContext(), ANSWER_THRESHOLD);
percentageToThreshold = Math.min(1, (difference * -1) / differenceThreshold);
backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.green_100), getResources().getColor(R.color.green_600));
if (percentageToThreshold > 0.5) {
foregroundColor = Color.WHITE;
} else {
foregroundColor = getResources().getColor(R.color.green_600);
}
answer.setTranslationY(difference);
if (percentageToThreshold == 1 && listener != null) {
answer.setVisibility(View.INVISIBLE);
lastY = event.getRawY();
if (!complete) {
complete = true;
listener.onAnswered();
}
}
} else {
differenceThreshold = ViewUtil.dpToPx(getContext(), DECLINE_THRESHOLD);
percentageToThreshold = Math.min(1, difference / differenceThreshold);
backgroundColor = (int) new ArgbEvaluator().evaluate(percentageToThreshold, getResources().getColor(R.color.red_100), getResources().getColor(R.color.red_600));
if (percentageToThreshold > 0.5) {
foregroundColor = Color.WHITE;
} else {
foregroundColor = getResources().getColor(R.color.green_600);
}
answer.setRotation(135 * percentageToThreshold);
if (percentageToThreshold == 1 && listener != null) {
answer.setVisibility(View.INVISIBLE);
lastY = event.getRawY();
if (!complete) {
complete = true;
listener.onDeclined();
}
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
answer.getBackground().setTint(backgroundColor);
answer.getDrawable().setTint(foregroundColor);
}
break;
}
return true;
}
private void animateElements(int delay) {
if (AccessibilityUtil.areAnimationsDisabled(getContext())) return;
ObjectAnimator fabUp = getUpAnimation(answer);
ObjectAnimator fabDown = getDownAnimation(answer);
ObjectAnimator fabShake = getShakeAnimation(answer);
animatorSet = new AnimatorSet();
animatorSet.play(fabUp).with(getUpAnimation(swipeUpText));
animatorSet.play(fabShake).after(fabUp);
animatorSet.play(fabDown).with(getDownAnimation(swipeUpText)).after(fabShake);
animatorSet.play(getFadeOut(swipeDownText)).with(fabUp);
animatorSet.play(getFadeIn(swipeDownText)).after(fabDown);
animatorSet.play(getShimmer(arrowFour, arrowThree, arrowTwo, arrowOne));
animatorSet.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
}
@Override
public void onAnimationEnd(Animator animation) {
if (animating) animateElements(1000);
}
@Override
public void onAnimationCancel(Animator animation) {}
@Override
public void onAnimationRepeat(Animator animation) {}
});
animatorSet.setStartDelay(delay);
animatorSet.start();
}
private void resetElements() {
animating = false;
complete = false;
if (animatorSet != null) animatorSet.cancel();
swipeUpText.setTranslationY(0);
answer.setTranslationY(0);
swipeDownText.setAlpha(1);
}
}
private static Animator getShimmer(View... targets) {
AnimatorSet animatorSet = new AnimatorSet();
int evenDuration = SHIMMER_TOTAL / targets.length;
int interval = 75;
for (int i=0;i<targets.length;i++) {
animatorSet.play(getShimmer(targets[i], evenDuration + (evenDuration - interval)))
.after(interval * i);
}
return animatorSet;
}
private static ObjectAnimator getShimmer(View target, int duration) {
ObjectAnimator shimmer = ObjectAnimator.ofFloat(target, "alpha", 0, 1, 0);
shimmer.setDuration(duration);
return shimmer;
}
private static ObjectAnimator getShakeAnimation(View target) {
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationX", 0, 25, -25, 25, -25,15, -15, 6, -6, 0);
animator.setDuration(SHAKE_TIME);
return animator;
}
private static ObjectAnimator getUpAnimation(View target) {
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationY", 0, -1 * ViewUtil.dpToPx(target.getContext(), 32));
animator.setInterpolator(new AccelerateInterpolator());
animator.setDuration(UP_TIME);
return animator;
}
private static ObjectAnimator getDownAnimation(View target) {
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "translationY", 0);
animator.setInterpolator(new DecelerateInterpolator());
animator.setDuration(DOWN_TIME);
return animator;
}
private static ObjectAnimator getFadeOut(View target) {
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "alpha", 1, 0);
animator.setDuration(FADE_OUT_TIME);
return animator;
}
private static ObjectAnimator getFadeIn(View target) {
ObjectAnimator animator = ObjectAnimator.ofFloat(target, "alpha", 0, 1);
animator.setDuration(FADE_IN_TIME);
return animator;
}
public interface AnswerDeclineListener {
void onAnswered();
void onDeclined();
}
}

View File

@@ -6,6 +6,7 @@ import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.Build;
import android.os.Looper;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
@@ -25,6 +26,7 @@ import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline;
import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import androidx.core.view.ViewKt;
import androidx.core.view.WindowInsetsCompat;
import androidx.recyclerview.widget.DefaultItemAnimator;
@@ -43,6 +45,7 @@ import com.google.common.collect.Sets;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.SetUtil;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.ResizeAnimation;
@@ -71,6 +74,8 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import kotlin.concurrent.ThreadsKt;
public class WebRtcCallView extends ConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
@@ -593,6 +598,7 @@ public class WebRtcCallView extends ConstraintLayout {
}
public void setStatus(@Nullable String status) {
ThreadUtil.assertMainThread();
this.status.setText(status);
collapsedToolbar.setSubtitle(status);
}

View File

@@ -162,7 +162,7 @@ public class WebRtcCallViewModel extends ViewModel {
return new InCallStatus.JoinedCallLinkUsers((int) participantsState.getParticipantCount().orElse(0));
}
}
).distinctUntilChanged();
).distinctUntilChanged().observeOn(AndroidSchedulers.mainThread());
}
public Observable<CallParticipantsState> getCallParticipantsState() {
@@ -190,7 +190,7 @@ public class WebRtcCallViewModel extends ViewModel {
}
public Observable<Boolean> shouldShowSpeakerHint() {
return shouldShowSpeakerHint;
return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread());
}
public WebRtcAudioOutput getCurrentAudioOutput() {

View File

@@ -57,7 +57,7 @@ public class TurnOffContactJoinedNotificationsActivity extends AppCompatActivity
ThreadTable threadTable = SignalDatabase.threads();
List<MessageTable.MarkedMessageInfo> marked = threadTable.setRead(getIntent().getLongExtra(EXTRA_THREAD_ID, -1), false);
MarkReadReceiver.process(this, marked);
MarkReadReceiver.process(marked);
SignalStore.settings().setNotifyWhenContactJoinsSignal(false);
ApplicationDependencies.getMessageNotifier().updateNotification(this);

View File

@@ -37,7 +37,7 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.MediaItem;
import androidx.media3.common.MediaItem;
import org.signal.core.util.logging.Log;
import org.signal.paging.PagingController;

View File

@@ -16,6 +16,7 @@ interface ConversationAdapterBridge {
const val PAYLOAD_TIMESTAMP = 0
const val PAYLOAD_NAME_COLORS = 1
const val PAYLOAD_SELECTED = 2
const val PAYLOAD_PARENT_SCROLLING = 3
}
fun hasNoConversationMessages(): Boolean

View File

@@ -57,9 +57,9 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.collect.Sets;
@@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectCollection;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.conversation.ui.payment.PaymentMessageView;
import org.thoughtcrime.securesms.conversation.v2.items.InteractiveConversationElement;
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationBodyUtil;
import org.thoughtcrime.securesms.conversation.v2.items.V2ConversationItemUtils;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.MessageTable;
@@ -135,6 +135,7 @@ import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.InterceptableLongClickCopyLinkSpan;
import org.thoughtcrime.securesms.util.LongClickMovementMethod;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.PlaceholderURLSpan;
import org.thoughtcrime.securesms.util.Projection;
@@ -240,6 +241,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final ProgressWheelClickListener progressWheelClickListener = new ProgressWheelClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
@@ -261,7 +263,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean hasWallpaper;
private float lastYDownRelativeToThis;
private ProjectionList colorizerProjections = new ProjectionList(3);
private boolean isBound = false;
private boolean isBound = false;
private final Runnable shrinkBubble = new Runnable() {
@Override
@@ -416,6 +418,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.conversationRecipient.observeForever(this);
}
@Override
public void setParentScrolling(boolean isParentScrolling) {
bodyBubble.setParentScrolling(isParentScrolling);
}
@Override
public void updateSelectedState() {
setHasBeenQuoted(conversationMessage);
@@ -554,22 +561,22 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int minSize = Math.min(maxBubbleWidth, Math.max(bodyText.getMeasuredWidth() + ViewUtil.dpToPx(6) + footerWidth + bodyMargins, bodyBubble.getMeasuredWidth()));
if (hasQuote(messageRecord) && sizeWithMargins < availableWidth) {
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
needsMeasure = true;
updatingFooter = true;
} else if (sizeWithMargins != bodyText.getMeasuredWidth() && sizeWithMargins <= minSize) {
bodyBubble.getLayoutParams().width = minSize;
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
needsMeasure = true;
updatingFooter = true;
}
}
if (!updatingFooter && !messageRecord.isFailed() && bodyText.getLastLineWidth() + ViewUtil.dpToPx(6) + footerWidth <= bodyText.getMeasuredWidth()) {
ViewUtil.setTopMargin(footer, collapsedTopMargin);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin);
ViewUtil.setTopMargin(footer, collapsedTopMargin, false);
ViewUtil.setBottomMargin(footer, collapsedBottomMargin, false);
updatingFooter = true;
needsMeasure = true;
}
@@ -577,8 +584,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int defaultTopMarginForRecord = getDefaultTopMarginForRecord(messageRecord, defaultTopMargin, defaultBottomMargin);
if (!updatingFooter && ViewUtil.getTopMargin(footer) != defaultTopMarginForRecord) {
ViewUtil.setTopMargin(footer, defaultTopMarginForRecord);
ViewUtil.setBottomMargin(footer, defaultBottomMargin);
ViewUtil.setTopMargin(footer, defaultTopMarginForRecord, false);
ViewUtil.setBottomMargin(footer, defaultBottomMargin, false);
needsMeasure = true;
}
@@ -1095,6 +1102,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (callToActionStub.resolved()) callToActionStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
revealableStub.get().setMessage((MmsMessageRecord) messageRecord, hasWallpaper);
@@ -1113,6 +1121,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
@@ -1134,6 +1143,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
//noinspection ConstantConditions
@@ -1159,6 +1169,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(linkPreview.getThumbnail().get())), showControls, false);
mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
linkPreviewStub.get().setLinkPreview(glideRequests, linkPreview, false);
@@ -1195,6 +1206,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
@@ -1222,6 +1234,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
//noinspection ConstantConditions
@@ -1250,6 +1263,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
if (hasSticker(messageRecord)) {
@@ -1281,6 +1295,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
List<Slide> thumbnailSlides = ((MmsMessageRecord) messageRecord).getSlideDeck().getThumbnailSlides();
@@ -1294,6 +1309,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
false);
mediaThumbnailStub.require().setThumbnailClickListener(new ThumbnailClickListener());
mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener);
mediaThumbnailStub.require().setProgressWheelClickListener(progressWheelClickListener);
mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener);
mediaThumbnailStub.require().setOnClickListener(passthroughClickListener);
mediaThumbnailStub.require().showShade(messageRecord.isDisplayBodyEmpty(getContext()) && !hasExtraText(messageRecord));
@@ -1335,6 +1351,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (linkPreviewStub.resolved()) linkPreviewStub.get().setVisibility(GONE);
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
MmsMessageRecord mmsMessageRecord = (MmsMessageRecord) messageRecord;
@@ -1351,6 +1368,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
@@ -1367,6 +1385,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (stickerStub.resolved()) stickerStub.get().setVisibility(View.GONE);
if (revealableStub.resolved()) revealableStub.get().setVisibility(View.GONE);
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
ViewUtil.updateLayoutParams(bodyText, ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
@@ -1515,7 +1534,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private void linkifyMessageBody(@NonNull Spannable messageBody,
boolean shouldLinkifyAllLinks)
{
V2ConversationBodyUtil.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
V2ConversationItemUtils.linkifyUrlLinks(messageBody, shouldLinkifyAllLinks, urlClickListener);
if (conversationMessage.hasStyleLinks()) {
for (PlaceholderURLSpan placeholder : messageBody.getSpans(0, messageBody.length(), PlaceholderURLSpan.class)) {
@@ -1535,6 +1554,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
messageBody.setSpan(new MentionClickableSpan(RecipientId.from(annotation.getValue())), messageBody.getSpanStart(annotation), messageBody.getSpanEnd(annotation), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
private void setStatusIcons(MessageRecord messageRecord, boolean hasWallpaper) {
bodyText.setCompoundDrawablesWithIntrinsicBounds(0, 0, messageRecord.isKeyExchange() ? R.drawable.ic_menu_login : 0, 0);
@@ -1611,17 +1631,17 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
if (!isFooterVisible(current, next, isGroupThread) && isStoryReaction(current)) {
ViewUtil.setBottomMargin(quoteView, (int) DimensionUnit.DP.toPixels(8));
ViewUtil.setBottomMargin(quoteView, (int) DimensionUnit.DP.toPixels(8), false);
} else {
ViewUtil.setBottomMargin(quoteView, 0);
ViewUtil.setBottomMargin(quoteView, 0, false);
}
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.require(), readDimen(R.dimen.message_bubble_top_padding));
ViewUtil.setTopMargin(mediaThumbnailStub.require(), readDimen(R.dimen.message_bubble_top_padding), false);
}
if (linkPreviewStub.resolved() && !hasBigImageLinkPreview(current)) {
ViewUtil.setTopMargin(linkPreviewStub.get(), readDimen(R.dimen.message_bubble_top_padding));
ViewUtil.setTopMargin(linkPreviewStub.get(), readDimen(R.dimen.message_bubble_top_padding), false);
}
} else {
if (quoteView != null) {
@@ -1630,7 +1650,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int topMargin = (current.isOutgoing() || !startOfCluster || !groupThread) ? 0 : readDimen(R.dimen.message_bubble_top_image_margin);
if (mediaThumbnailStub.resolved()) {
ViewUtil.setTopMargin(mediaThumbnailStub.require(), topMargin);
ViewUtil.setTopMargin(mediaThumbnailStub.require(), topMargin, false);
}
}
}
@@ -1661,7 +1681,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setReactionsWithWidth(@NonNull MessageRecord current, int width) {
reactionsView.setReactions(current.getReactions(), width);
reactionsView.setReactions(current.getReactions());
reactionsView.setBubbleWidth(width);
reactionsView.setOnClickListener(v -> {
if (eventListener == null) return;
@@ -2293,6 +2314,15 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return bodyBubble;
}
@Override
public void invalidateChatColorsDrawable(@NonNull ViewGroup coordinateRoot) {
// Intentionally left blank.
}
@Override public @Nullable SnapshotStrategy getSnapshotStrategy() {
return null;
}
private class SharedContactEventListener implements SharedContactView.EventListener {
@Override
public void onAddToContactsClicked(@NonNull Contact contact) {
@@ -2410,6 +2440,20 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class ProgressWheelClickListener implements SlideClickListener {
@Override
public void onClick(View v, Slide slide) {
final boolean isIncremental = slide.asAttachment().getIncrementalDigest() != null;
final boolean contentTypeSupported = MediaUtil.isVideoType(slide.getContentType());
if (FeatureFlags.instantVideoPlayback() && isIncremental && contentTypeSupported) {
launchMediaPreview(v, slide);
} else {
Log.d(TAG, "Non-eligible slide clicked: " + "\tisIncremental: " + isIncremental + "\tcontentTypeSupported: " + contentTypeSupported);
}
}
}
private class SlideClickPassthroughListener implements SlideClickListener {
private final SlidesClickedListener original;
@@ -2443,34 +2487,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
} else if (!canPlayContent && mediaItem != null && eventListener != null) {
eventListener.onPlayInlineContent(conversationMessage);
} else if (MediaPreviewV2Fragment.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) {
if (eventListener == null) {
return;
}
MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs(
messageRecord.getThreadId(),
messageRecord.getTimestamp(),
slide.getUri(),
slide.getContentType(),
slide.asAttachment().getSize(),
slide.getCaption().orElse(null),
false,
false,
false,
false,
MediaTable.Sorting.Newest,
slide.isVideoGif(),
new MediaIntentFactory.SharedElementArgs(
slide.asAttachment().getWidth(),
slide.asAttachment().getHeight(),
mediaThumbnailStub.require().getCorners().getTopLeft(),
mediaThumbnailStub.require().getCorners().getTopRight(),
mediaThumbnailStub.require().getCorners().getBottomRight(),
mediaThumbnailStub.require().getCorners().getBottomLeft()
),
false);
MediaPreviewCache.INSTANCE.setDrawable(((ThumbnailView) v).getImageDrawable());
eventListener.goToMediaPreview(ConversationItem.this, v, args);
launchMediaPreview(v, slide);
} else if (slide.getUri() != null) {
Log.i(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType());
Uri publicUri = PartAuthority.getAttachmentPublicUri(slide.getUri());
@@ -2507,6 +2524,47 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void launchMediaPreview(View v, Slide slide) {
if (eventListener == null) {
Log.w(TAG, "Could not launch media preview for item: eventListener was null");
return;
}
Uri mediaUri = slide.getUri();
if (mediaUri == null) {
Log.w(TAG, "Could not launch media preview for item: uri was null");
return;
}
MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs(
messageRecord.getThreadId(),
messageRecord.getTimestamp(),
mediaUri,
slide.getContentType(),
slide.asAttachment().getSize(),
slide.getCaption().orElse(null),
false,
false,
false,
false,
MediaTable.Sorting.Newest,
slide.isVideoGif(),
new MediaIntentFactory.SharedElementArgs(
slide.asAttachment().getWidth(),
slide.asAttachment().getHeight(),
mediaThumbnailStub.require().getCorners().getTopLeft(),
mediaThumbnailStub.require().getCorners().getTopRight(),
mediaThumbnailStub.require().getCorners().getBottomRight(),
mediaThumbnailStub.require().getCorners().getBottomLeft()
),
false);
if (v instanceof ThumbnailView) {
MediaPreviewCache.INSTANCE.setDrawable(((ThumbnailView) v).getImageDrawable());
}
eventListener.goToMediaPreview(ConversationItem.this, v, args);
}
private class PassthroughClickListener implements View.OnLongClickListener, View.OnClickListener {
@Override

View File

@@ -12,6 +12,7 @@ import androidx.annotation.Nullable;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.Outliner;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.Util;
@@ -30,19 +31,33 @@ public class ConversationItemBodyBubble extends LinearLayout {
private Projection quoteViewProjection;
private Projection videoPlayerProjection;
private final BodyBubbleLayoutTransition bodyBubbleLayoutTransition = new BodyBubbleLayoutTransition();
public ConversationItemBodyBubble(Context context) {
super(context);
setLayoutTransition(new BodyBubbleLayoutTransition());
init();
}
public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
setLayoutTransition(new BodyBubbleLayoutTransition());
init();
}
public ConversationItemBodyBubble(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setLayoutTransition(new BodyBubbleLayoutTransition());
init();
}
private void init() {
setLayoutTransition(bodyBubbleLayoutTransition);
}
public void setParentScrolling(boolean isParentScrolling) {
if (isParentScrolling) {
setLayoutTransition(null);
} else {
setLayoutTransition(bodyBubbleLayoutTransition);
}
}
public void setOutliners(@NonNull List<Outliner> outliners) {

View File

@@ -43,6 +43,13 @@ object ConversationItemSelection {
drawConversationItem: Boolean,
hasReaction: Boolean
): Bitmap {
val snapshotStrategy = target.getSnapshotStrategy()
if (snapshotStrategy != null) {
return createBitmap(target.root.width, target.root.height).applyCanvas {
snapshotStrategy.snapshot(this)
}
}
val bodyBubble = target.bubbleView
val reactionsView = target.reactionsView

View File

@@ -16,16 +16,18 @@ import org.thoughtcrime.securesms.database.BodyRangeUtil;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.security.MessageDigest;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Locale;
/**
* A view level model used to pass arbitrary message related information needed
@@ -43,6 +45,7 @@ public class ConversationMessage {
@NonNull private final Recipient threadRecipient;
private final boolean hasBeenQuoted;
@Nullable private final MessageRecord originalMessage;
@NonNull private final String formattedDate;
private ConversationMessage(@NonNull MessageRecord messageRecord,
@Nullable CharSequence body,
@@ -50,7 +53,8 @@ public class ConversationMessage {
boolean hasBeenQuoted,
@Nullable MessageStyler.Result styleResult,
@NonNull Recipient threadRecipient,
@Nullable MessageRecord originalMessage)
@Nullable MessageRecord originalMessage,
@NonNull String formattedDate)
{
this.messageRecord = messageRecord;
this.hasBeenQuoted = hasBeenQuoted;
@@ -58,6 +62,7 @@ public class ConversationMessage {
this.styleResult = styleResult != null ? styleResult : MessageStyler.Result.none();
this.threadRecipient = threadRecipient;
this.originalMessage = originalMessage;
this.formattedDate = formattedDate;
if (body != null) {
this.body = SpannableString.valueOf(body);
@@ -90,6 +95,11 @@ public class ConversationMessage {
return hasBeenQuoted;
}
@NonNull
public String getFormattedDate() {
return formattedDate;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
@@ -194,13 +204,16 @@ public class ConversationMessage {
}
}
String formattedDate = MessageRecordUtil.isScheduled(messageRecord) ? DateUtils.getOnlyTimeString(context, Locale.getDefault(), ((MediaMmsMessageRecord) messageRecord).getScheduledDate())
: DateUtils.getDatelessRelativeTimeSpanString(context, Locale.getDefault(), messageRecord.getTimestamp());
return new ConversationMessage(messageRecord,
styledAndMentionBody != null ? styledAndMentionBody : mentionsUpdate != null ? mentionsUpdate.getBody() : body,
mentionsUpdate != null ? mentionsUpdate.getMentions() : null,
hasBeenQuoted,
styleResult,
threadRecipient,
originalMessage);
originalMessage,
formattedDate);
}
/**

View File

@@ -189,10 +189,6 @@ internal object ConversationOptionsMenu {
}
})
if (threadId == -1L) {
hideMenuItem(menu, R.id.menu_view_media)
}
menu.findItem(R.id.menu_format_text_submenu).subMenu?.clearHeader()
menu.findItem(R.id.edittext_bold).applyTitleSpan(MessageStyler.boldStyle())
menu.findItem(R.id.edittext_italic).applyTitleSpan(MessageStyler.italicStyle())

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