Compare commits

..

198 Commits

Author SHA1 Message Date
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
Clark Chen
e11750fb75 Bump version to 6.29.1 2023-08-14 16:33:01 -04:00
Clark Chen
1634ddeb25 Updated language translations. 2023-08-14 16:14:17 -04:00
Clark
7d4bcd7f15 Ignore message_fts table if needed in v175 migration. 2023-08-14 15:59:50 -04:00
Cody Henthorne
13d9b6cc5a Fix incorrect unread counts. 2023-08-14 15:59:50 -04:00
Clark
8d0c41baa0 Update edit message and remote delete send/receive thresholds. 2023-08-14 15:59:50 -04:00
Alex Hart
0303c96ee1 Fix usage of setAvatar in ConversationListItem. 2023-08-14 15:59:50 -04:00
Alex Hart
fde6d7921e Bounce message request state update if needed. 2023-08-14 15:59:50 -04:00
Alex Hart
c632d8ebec Remove group calling tooltip. 2023-08-14 15:59:50 -04:00
Alex Hart
31b43e8754 Fix thread set query during row deletion. 2023-08-14 15:59:50 -04:00
Alex Hart
195360a0f9 Fix quote reply scroll-to-bottom behavior. 2023-08-11 14:06:11 -03:00
Alex Hart
f293f88958 Fix strange RTL white screen behavior. 2023-08-11 13:37:16 -03:00
Alex Hart
6ccfab4087 Bump version to 6.29.0 2023-08-10 15:38:34 -03:00
Alex Hart
a45ce55808 Updated language translations. 2023-08-10 15:32:05 -03:00
Greyson Parrelli
c7dabe1b6f Ensure all group recipients have group records. 2023-08-10 15:29:02 -03:00
Alex Hart
ec51268439 Update Fragment and RecyclerView libraries.
Update Fragment to 1.6.1
Update RecyclerView to 1.3.1
2023-08-10 15:29:02 -03:00
Clark
7543b9fa37 Fix hidden recipients instrumentation tests. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
ca3187d0b8 Ungate some PNP receive-side behavior. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
327cd93e3c Save PNI's from CDSv2 for all users. 2023-08-10 15:29:02 -03:00
Alex Hart
13853c708e Implement proper in-call status for call links. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
ee1291c816 Improve logging of (ACI, PNI, E164) tuples. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
6d2d3ae528 Improve ServiceId parsing functions. 2023-08-10 15:29:02 -03:00
Alex Hart
784f94ecdb Fix missed call icon for groups. 2023-08-10 15:29:02 -03:00
Alex Hart
93bf853b5e Fix threading in call link creation sheet. 2023-08-10 15:29:02 -03:00
Clark
bb83ddfe28 Prompt user for debug logs with slow notifications. 2023-08-10 15:29:02 -03:00
Clark
b51ec53e33 Light battery optimizations cleanup. 2023-08-10 15:29:02 -03:00
Alex Hart
ca210f2b6d Add denial dialogs for call links. 2023-08-10 15:29:02 -03:00
Alex Hart
38bddec4ba Fix call deletion sync message sending. 2023-08-10 15:29:02 -03:00
Alex Hart
b866d57814 Hide admin options if user is not a call admin. 2023-08-10 15:29:02 -03:00
Alex Hart
3c9004d87d Remove maximum denial tracking. 2023-08-10 15:29:02 -03:00
Alex Hart
c479dd404c Add invalid call link dialog. 2023-08-10 15:29:02 -03:00
Alex Hart
748667a0b4 Fix bad ad hoc calling flag check. 2023-08-10 15:29:02 -03:00
Alex Hart
6898595f8a Add GroupCall.JoinState.PENDING support. 2023-08-10 15:29:02 -03:00
Alex Hart
30d0b6fd0e Add additional call links moderation ui. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
7c209db146 Fix logging for 'restricted' power bucket. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
49c8c88a22 Put message latency time in decryption log. 2023-08-10 15:29:02 -03:00
Alex Hart
88e530c96c Rotate edit message flag. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
14f3fb5a94 Break message-latency into high/low priority. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
7ac479b78a Log server time offset in FCM log. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
10b356e642 Stop reading the giftBadges capability. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
7f92482d7a Stop reading the stories capability. 2023-08-10 15:29:02 -03:00
Alex Hart
b79a7309aa Allow input panel to grow as text is entered. 2023-08-10 15:29:02 -03:00
Alex Hart
b54781ff56 Utilize AlertDialog.Builder for displayInDialogAboveAnchor. 2023-08-10 15:29:02 -03:00
Clark
6a87495a6d Update contact hiding to spec. 2023-08-10 15:29:02 -03:00
Greyson Parrelli
c5d9346370 Convert all group code to be based on ServiceIds. 2023-08-10 15:05:18 -03:00
Alex Hart
d247e2c111 Implement several parts of the call links admin UX. 2023-08-10 15:05:18 -03:00
Cody Henthorne
b30f47bac4 Remove ComposeText and SendButton sms/mms transport complexity. 2023-08-10 15:05:18 -03:00
Cody Henthorne
2f9498e137 Refactor input panel to use constraint layout. 2023-08-10 15:05:18 -03:00
Alex Hart
067b3513b7 Add content descriptions to call log row item buttons. 2023-08-10 15:05:18 -03:00
Alex Hart
e50ed22c85 Bump version to 6.28.5 2023-08-10 14:47:22 -03:00
Alex Hart
7cf17f3cc4 Updated language translations. 2023-08-10 14:42:19 -03:00
Greyson Parrelli
9f52ecab5c Ensure that inbound messages mark threads as active. 2023-08-10 13:33:23 -04:00
Alex Hart
c8a56d4f78 Bump version to 6.28.4 2023-08-09 14:02:20 -03:00
Alex Hart
71d482ab29 Updated language translations. 2023-08-09 14:00:24 -03:00
Greyson Parrelli
1cc7b46555 Fix PNI prefixing in provisioning message. 2023-08-09 13:57:32 -03:00
Alex Hart
f56a65d30d Bump version to 6.28.3 2023-08-09 09:42:28 -03:00
Alex Hart
aff813b284 Updated language translations. 2023-08-09 09:29:37 -03:00
Alex Hart
181c0e8a60 Revert "Fix unread state using last seen timestamp."
This reverts commit a5e30bc818.
2023-08-09 09:24:40 -03:00
Alex Hart
cf7f614296 Revert "Use local timestamps for in-chat unread counter."
This reverts commit c501a417bb.
2023-08-09 09:24:19 -03:00
Alex Hart
351e37bcee Bump version to 6.28.2 2023-08-07 15:52:47 -03:00
Alex Hart
cc1f27f588 Updated language translations. 2023-08-07 15:49:02 -03:00
Alex Hart
859905c3e4 Fix sticker insertion from system keyboard. 2023-08-07 15:41:49 -03:00
Alex Hart
8af91bffb5 Fix expanding captions. 2023-08-07 15:41:49 -03:00
Alex Hart
06dc8ccbdd Fix contact photo upload failure. 2023-08-07 15:28:22 -03:00
Alex Hart
c501a417bb Use local timestamps for in-chat unread counter. 2023-08-07 13:28:19 -03:00
Alex Hart
0021e229d8 Add virtual file support to file sharing. 2023-08-07 13:27:55 -03:00
Alex Hart
b4ef95a9b4 Add ActivityNotFoundException handling to ConversationFragment. 2023-08-04 15:17:47 -03:00
Greyson Parrelli
f25a716d62 Bump version to 6.28.1 2023-08-04 12:47:40 -04:00
Greyson Parrelli
a9739ed500 Updated language translations. 2023-08-04 12:47:04 -04:00
Alex Hart
a131eeaa4a Add recaptcha triggers to CFV2. 2023-08-04 13:30:05 -03:00
Alex Hart
9382bbd8fd Add first time in group check. 2023-08-04 13:24:00 -03:00
Greyson Parrelli
adb1e292bf Convert Scrubber to kotlin. 2023-08-04 12:17:05 -04:00
Greyson Parrelli
ca79929141 Add additional Scrubber tests. 2023-08-04 11:43:33 -04:00
Greyson Parrelli
ae2998bcf2 Actually use db reference passed into SearchTable.fullyResetTables. 2023-08-04 11:04:52 -04:00
Alex Hart
1e8e09d5c4 Add multiselect callback to conversation fragment. 2023-08-04 11:57:03 -03:00
Alex Hart
a5e30bc818 Fix unread state using last seen timestamp. 2023-08-04 11:53:16 -03:00
Alex Hart
72edf5c08b Ignore call links check if ff is disabled. 2023-08-04 11:34:43 -03:00
Alex Hart
192154a11c Update reminder on ReminderUpdateEvent broadcast. 2023-08-04 10:21:45 -03:00
Cody Henthorne
c3700cf6d9 Fix incorrect read state causing stale notifications and tweak scroll to bottom behavior. 2023-08-04 09:31:03 -03:00
Greyson Parrelli
78bdee61ef Bump version to 6.28.0 2023-08-02 18:00:00 -04:00
Greyson Parrelli
b84eea9620 Updated language translations. 2023-08-02 17:59:21 -04:00
Greyson Parrelli
5f289fa400 Refactor RecipientTable with a PNI constraint. 2023-08-02 17:49:53 -04:00
Cody Henthorne
67b8f468e4 Remove most of Conversation Fragment V1 and friends. 2023-08-02 17:49:53 -04:00
Alex Hart
9c49c84306 Fix poor linkpreview popin behavior in cfv2. 2023-08-02 17:49:53 -04:00
Jon Chambers
b31ee802fc Update KBS service ID in staging. 2023-08-02 12:13:58 -04:00
Clark
b893a0eb76 Refresh contact list after hiding a contact. 2023-08-02 10:21:40 -04:00
Clark
6f1a04abce Add dialog for when you hit edit message limit. 2023-08-02 09:47:38 -04:00
Clark
041ba27efe Show hidden contacts with chats when searching. 2023-08-01 15:51:31 -04:00
Alex Hart
e239036d8b Send 'clear history' event when clearing the call log. 2023-08-01 15:51:31 -04:00
Clark
d3f073e573 Fix edit message when sending via legacy path. 2023-08-01 15:51:31 -04:00
Clark
0b7490dc06 Update edit message history items to match design. 2023-08-01 15:51:31 -04:00
Clark
a0e514dac9 Enqueue download jobs for edit messages. 2023-08-01 15:51:31 -04:00
Alex Hart
2f1eaf7d6b Fix coloring of media overview toolbar in dark mode. 2023-08-01 15:51:31 -04:00
Alex Hart
6cb8b1c439 Fix call link icon tint in call log. 2023-08-01 15:51:31 -04:00
Alex Hart
5363208e4e Fix typo in method name. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
510ff51198 Rotate the edit message feature flag. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
6dde3d55ef Fix Spinner JS imports. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
e3ec53c2d0 Remove deprecated SMS fields from recipient table. 2023-08-01 15:51:31 -04:00
Cody Henthorne
e7972d4903 Update request and response properties for batch identity checks. 2023-08-01 15:51:31 -04:00
Jordan Rose
a2c3b5d64e Adopt libsignal 0.30.0 and ServiceIds for group members.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2023-08-01 15:51:31 -04:00
Clark
b11d653fc0 Exit edit message mode on message send. 2023-08-01 15:51:31 -04:00
Clark
66792f2d56 Add heuristics for delayed notifications. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
c012ead143 Validate ServiceIds on envelopes. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
82906aee58 Use strongly-typed ACIs and PNIs everywhere. 2023-08-01 15:51:31 -04:00
Nicholas
7ff4a82755 Show popup on switching to/from speakerphone. 2023-08-01 15:51:31 -04:00
Jordan Rose
8ca49c1e18 Update to RingRTC v2.30.0 2023-08-01 15:51:31 -04:00
Nicholas
7d68a57f53 Fall back to AudioCodec if MediaRecorderWrapper fails. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
c68487c0c7 Disable ktlint rule around class naming. 2023-08-01 15:51:31 -04:00
Clark
4adc660705 Stop content provider handler threads on release. 2023-08-01 15:51:31 -04:00
Clark
d78e73bd6f Fix search showing received mention messages as note to self. 2023-08-01 15:51:31 -04:00
Clark Chen
9d33690f34 Show read more for super long scheduled messages. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
4c428e5b5b Update to new CDS flag. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
4c3882689f Let PNP feature flag override CDS compat flag. 2023-08-01 15:51:31 -04:00
Greyson Parrelli
c82ed473fc Bump version to 6.27.10 2023-08-01 15:49:51 -04:00
Greyson Parrelli
06664b4c58 Updated language translations. 2023-08-01 15:49:15 -04:00
Cody Henthorne
5e68388b01 Fix crash when on recipient change called after requesting to remove observer. 2023-08-01 15:31:34 -04:00
661 changed files with 27894 additions and 30727 deletions

View File

@@ -5,4 +5,5 @@ indent_size = 2
ij_kotlin_allow_trailing_comma_on_call_site = false
ij_kotlin_allow_trailing_comma = false
ktlint_code_style = intellij_idea
twitter_compose_allowed_composition_locals=LocalExtendedColors
twitter_compose_allowed_composition_locals=LocalExtendedColors
ktlint_standard_class-naming = disabled

View File

@@ -10,7 +10,6 @@ plugins {
id 'app.cash.exhaustive'
id 'kotlin-parcelize'
id 'com.squareup.wire'
id 'android-constants'
id 'translations'
}
@@ -45,8 +44,8 @@ ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1303
def canonicalVersionName = "6.27.9"
def canonicalVersionCode = 1319
def canonicalVersionName = "6.30.4"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -94,7 +93,7 @@ android {
testBuildType 'instrumentation'
kotlinOptions {
jvmTarget = "11"
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = ["-Xallow-result-return-type"]
}
@@ -382,7 +381,7 @@ android {
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
"\"ee1d0d972b7ea903615670de43ab1b6e7a825e811c70a29bb5fe0f819e0975fa\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
@@ -511,7 +510,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

View File

@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.testing.timeout
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.push.MismatchedDevices
import org.whispersystems.signalservice.internal.push.PreKeyState
import java.util.UUID
@@ -73,7 +74,7 @@ class ChangeNumberViewModelTest {
fun testChangeNumber_givenOnlyPrimaryAndNoRegLock() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = ServiceId.from(UUID.randomUUID())
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
@@ -180,7 +181,7 @@ class ChangeNumberViewModelTest {
val aci = Recipient.self().requireServiceId()
val oldPni = Recipient.self().requirePni()
val oldE164 = Recipient.self().requireE164()
val newPni = ServiceId.from(UUID.randomUUID())
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
@@ -225,7 +226,7 @@ class ChangeNumberViewModelTest {
fun testChangeNumber_givenOnlyPrimaryAndRegistrationLock() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = ServiceId.from(UUID.randomUUID())
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
@@ -269,7 +270,7 @@ class ChangeNumberViewModelTest {
fun testChangeNumber_givenMismatchedDevicesOnFirstCall() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = ServiceId.from(UUID.randomUUID())
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
@@ -313,7 +314,7 @@ class ChangeNumberViewModelTest {
fun testChangeNumber_givenRegLockAndMismatchedDevicesOnFirstTwoCalls() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = ServiceId.from(UUID.randomUUID())
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState

View File

@@ -8,6 +8,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage

View File

@@ -7,6 +7,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId

View File

@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.util.UUID
class DistributionListTablesTest {

View File

@@ -294,12 +294,12 @@ class GroupTableTest {
private fun insertPushGroup(
members: List<DecryptedMember> = listOf(
DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setAciBytes(harness.self.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(harness.others[0]).requireServiceId().toByteString())
.setAciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
@@ -318,14 +318,14 @@ class GroupTableTest {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
.setUuid(harness.self.requireServiceId().toByteString())
.setAciBytes(harness.self.requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()
val otherMembers: List<DecryptedMember> = others.map { id ->
DecryptedMember.newBuilder()
.setUuid(Recipient.resolved(id).requireServiceId().toByteString())
.setAciBytes(Recipient.resolved(id).requireAci().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build()

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

@@ -10,9 +10,8 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
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
@Suppress("ClassName")
@@ -34,7 +33,7 @@ class MessageTableTest_gifts {
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
}
@Test

View File

@@ -17,7 +17,6 @@ object MmsHelper {
recipient: Recipient = Recipient.UNKNOWN,
body: String = "body",
sentTimeMillis: Long = System.currentTimeMillis(),
subscriptionId: Int = -1,
expiresIn: Long = 0,
viewOnce: Boolean = false,
distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
@@ -32,7 +31,6 @@ object MmsHelper {
recipient = recipient,
body = body,
timestamp = sentTimeMillis,
subscriptionId = subscriptionId,
expiresIn = expiresIn,
viewOnce = viewOnce,
distributionType = distributionType,

View File

@@ -16,9 +16,8 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
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
import java.util.concurrent.TimeUnit
@@ -45,7 +44,7 @@ class MmsTableTest_stories {
SignalStore.account().setPni(localPni)
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelRecipient.id)

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

@@ -13,8 +13,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@@ -24,14 +24,14 @@ class RecipientTableTest {
val harness = SignalActivityRule()
@Test
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIDoNotExpectHiddenToBeReturned() {
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIExpectHiddenToBeReturned() {
val hiddenRecipient = harness.others[0]
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
SignalDatabase.recipients.markHidden(hiddenRecipient)
val results = SignalDatabase.recipients.queryAllContacts("Hidden")!!
assertEquals(0, results.count)
assertEquals(1, results.count)
}
@Test
@@ -173,10 +173,10 @@ class RecipientTableTest {
SignalDatabase.recipients.markUnregistered(mainId)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)
@@ -192,10 +192,10 @@ class RecipientTableTest {
SignalDatabase.recipients.splitForStorageSync(mainRecord.storageId!!)
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
assertEquals(mainId, byAci)
assertEquals(byE164, byPni)

View File

@@ -7,6 +7,7 @@ import org.hamcrest.MatcherAssert
import org.hamcrest.Matchers
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -14,6 +15,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.SqlUtil
import org.signal.core.util.exists
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
@@ -36,14 +38,13 @@ import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.Optional
import java.util.UUID
@@ -84,6 +85,30 @@ class RecipientTableTest_getAndPossiblyMerge {
val record = SignalDatabase.recipients.getRecord(id)
assertEquals(RecipientTable.RegisteredState.REGISTERED, record.registered)
}
test("e164+pni insert") {
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") {
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") {
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)
}
}
@Test
@@ -93,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") {
@@ -103,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)
@@ -167,6 +197,12 @@ class RecipientTableTest_getAndPossiblyMerge {
expectSessionSwitchoverEvent(E164_A)
}
test("e164 and pni matches, all provided, new aci, existing pni session, pni-verified") {
given(E164_A, PNI_A, null, pniSession = true)
process(E164_A, PNI_A, ACI_A, pniVerified = true)
expect(E164_A, PNI_A, ACI_A)
}
test("e164 and aci matches, all provided, new pni") {
given(E164_A, null, ACI_A)
process(E164_A, PNI_A, ACI_A)
@@ -309,6 +345,26 @@ class RecipientTableTest_getAndPossiblyMerge {
expectChangeNumberEvent()
}
test("steal, pni is changed") {
given(E164_A, PNI_B, ACI_A)
given(E164_B, PNI_A, null)
process(E164_A, PNI_A, null)
expect(E164_A, PNI_A, ACI_A)
expect(E164_B, null, null)
}
test("steal, pni is changed, aci left behind") {
given(E164_B, PNI_A, ACI_A)
given(E164_A, PNI_B, null)
process(E164_A, PNI_A, null)
expect(E164_B, null, ACI_A)
expect(E164_A, PNI_A, null)
}
test("steal, e164+pni & e164+pni, no aci provided, no pni session") {
given(E164_A, PNI_B, null)
given(E164_B, PNI_A, null)
@@ -353,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)
@@ -366,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)
@@ -378,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)
@@ -502,7 +626,7 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent(E164_A)
}
test("merge, e164+pni & aci, pni session, thread merge shadows") {
test("merge, e164+pni & aci, pni session, thread merge shadows SSE") {
given(E164_A, PNI_A, null, pniSession = true)
given(null, null, ACI_A)
@@ -600,6 +724,18 @@ class RecipientTableTest_getAndPossiblyMerge {
expectThreadMergeEvent(E164_A)
}
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("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
given(E164_SELF, null, ACI_SELF)
given(null, null, ACI_A)
@@ -621,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)
}
}
/**
@@ -768,9 +920,15 @@ class RecipientTableTest_getAndPossiblyMerge {
}
private fun identityKey(value: Byte): IdentityKey {
val byteArray = ByteArray(32)
byteArray[0] = value
return identityKey(byteArray)
}
private fun identityKey(value: ByteArray): IdentityKey {
val bytes = ByteArray(33)
bytes[0] = 0x05
bytes[1] = value
value.copyInto(bytes, 1)
return IdentityKey(bytes)
}
@@ -867,14 +1025,15 @@ 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
if (createThread) {
// Create a thread and throw a dummy message in it so it doesn't get automatically deleted
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, (Math.random() * Long.MAX_VALUE).toLong(), 0, 0, "", Optional.empty(), 0, false, ""), ""))
val result = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = id, time = (Math.random() * 10000000).toLong(), body = "1"))
SignalDatabase.threads.markAsActiveEarly(result.get().threadId)
}
if (pniSession) {
@@ -885,11 +1044,42 @@ 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(),
recipientId = id,
identityKey = identityKey(Util.getSecretBytes(32)),
verifiedStatus = IdentityTable.VerifiedStatus.DEFAULT,
firstUse = true,
timestamp = 0,
nonBlockingApproval = false
)
}
if (pni != null) {
SignalDatabase.identities.saveIdentity(
addressName = pni.toString(),
recipientId = id,
identityKey = identityKey(Util.getSecretBytes(32)),
verifiedStatus = IdentityTable.VerifiedStatus.DEFAULT,
firstUse = true,
timestamp = 0,
nonBlockingApproval = false
)
}
return id
}
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false, pniVerified: Boolean = false): RecipientId {
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci = aci, pni = pni, e164 = e164, pniVerified = pniVerified, changeSelf = changeSelf)
generatedIds += outputRecipientId
return outputRecipientId
}
@@ -903,15 +1093,15 @@ class RecipientTableTest_getAndPossiblyMerge {
val expected = RecipientTuple(
e164 = e164,
pni = pni,
serviceId = aci ?: pni
aci = aci
)
val actual = RecipientTuple(
e164 = recipient.e164.orElse(null),
pni = recipient.pni.orElse(null),
serviceId = recipient.serviceId.orElse(null)
aci = recipient.aci.orElse(null)
)
assertEquals(expected, actual)
assertEquals("Recipient $id did not match expected result!", expected, actual)
}
fun expectDeleted() {
@@ -919,21 +1109,21 @@ class RecipientTableTest_getAndPossiblyMerge {
}
fun expectDeleted(id: RecipientId) {
SignalDatabase.rawDatabase
.select("1")
.from(RecipientTable.TABLE_NAME)
val found = SignalDatabase.rawDatabase
.exists(RecipientTable.TABLE_NAME)
.where("${RecipientTable.ID} = ?", id)
.run()
.use { !it.moveToFirst() }
assertFalse("Expected $id to be deleted, but it's still present!", found)
}
fun expectChangeNumberEvent() {
assertEquals(1, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
assertEquals("Missing change number event!", 1, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
changeNumberExpected = true
}
fun expectNoChangeNumberEvent() {
assertEquals(0, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
assertEquals("Unexpected change number event!", 0, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
changeNumberExpected = false
}
@@ -943,42 +1133,39 @@ class RecipientTableTest_getAndPossiblyMerge {
fun expectSessionSwitchoverEvent(recipientId: RecipientId, e164: String) {
val event: SessionSwitchoverEvent? = getLatestSessionSwitchoverEvent(recipientId)
assertNotNull(event)
assertNotNull("Missing session switchover event! Expected one with e164 = $e164", event)
assertEquals(e164, event!!.e164)
sessionSwitchoverExpected = true
}
fun expectNoSessionSwitchoverEvent() {
assertNull(getLatestSessionSwitchoverEvent(outputRecipientId))
assertNull("Unexpected session switchover event!", getLatestSessionSwitchoverEvent(outputRecipientId))
}
fun expectThreadMergeEvent(previousE164: String) {
val event: ThreadMergeEvent? = getLatestThreadMergeEvent(outputRecipientId)
assertNotNull(event)
assertEquals(previousE164, event!!.previousE164)
assertNotNull("Missing thread merge event! Expected one with e164 = $previousE164", event)
assertEquals("E164 on thread merge event doesn't match!", previousE164, event!!.previousE164)
threadMergeExpected = true
}
fun expectNoThreadMergeEvent() {
assertNull(getLatestThreadMergeEvent(outputRecipientId))
assertNull("Unexpected thread merge event!", getLatestThreadMergeEvent(outputRecipientId))
}
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
val serviceIdString: String? = (aci ?: pni)?.toString()
val pniString: String? = pni?.toString()
val id: Long = SignalDatabase.rawDatabase.insert(
RecipientTable.TABLE_NAME,
null,
contentValuesOf(
RecipientTable.PHONE to e164,
RecipientTable.SERVICE_ID to serviceIdString,
RecipientTable.PNI_COLUMN to pniString,
RecipientTable.E164 to e164,
RecipientTable.ACI_COLUMN to aci?.toString(),
RecipientTable.PNI_COLUMN to pni?.toString(),
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
)
)
assertTrue("Failed to insert! E164: $e164, ServiceId: $serviceIdString, PNI: $pniString", id > 0)
assertTrue("Failed to insert! E164: $e164, ACI: $aci, PNI: $pni", id > 0)
return RecipientId.from(id)
}
@@ -987,14 +1174,14 @@ class RecipientTableTest_getAndPossiblyMerge {
data class RecipientTuple(
val e164: String?,
val pni: PNI?,
val serviceId: ServiceId?
val aci: ACI?
) {
/**
* The intent here is to give nice diffs with the name of the constants rather than the values.
*/
override fun toString(): String {
return "(${e164.e164String()}, ${pni.pniString()}, ${serviceId.serviceIdString()})"
return "(${e164.e164String()}, ${pni.pniString()}, ${aci.aciString()})"
}
private fun String?.e164String(): String {
@@ -1018,12 +1205,9 @@ class RecipientTableTest_getAndPossiblyMerge {
} ?: "null"
}
private fun ServiceId?.serviceIdString(): String {
private fun ACI?.aciString(): String {
return this?.let {
when (it) {
PNI_A -> "PNI_A"
PNI_B -> "PNI_B"
PNI_SELF -> "PNI_SELF"
ACI_A -> "ACI_A"
ACI_B -> "ACI_B"
ACI_SELF -> "ACI_SELF"

View File

@@ -21,9 +21,8 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
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.Optional
import java.util.UUID
@@ -283,8 +282,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
}
companion object {
private val aliceServiceId: ServiceId = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
private val bobServiceId: ServiceId = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
private val aliceServiceId: ACI = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
private val bobServiceId: ACI = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
private val masterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
private val groupId = GroupId.v2(masterKey)

View File

@@ -21,7 +21,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@@ -465,7 +465,7 @@ class StorySendTableTest {
private fun makeRecipients(count: Int): List<RecipientId> {
return (1..count).map {
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
}
}
}

View File

@@ -14,7 +14,7 @@ import org.junit.Test
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.util.UUID
@Suppress("ClassName")
@@ -28,7 +28,7 @@ class ThreadTableTest_active {
@Before
fun setUp() {
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
}
@Test

View File

@@ -9,7 +9,7 @@ import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.util.UUID
@Suppress("ClassName")
@@ -23,7 +23,7 @@ class ThreadTableTest_pinned {
@Before
fun setUp() {
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
}
@Test

View File

@@ -10,7 +10,7 @@ import org.signal.core.util.CursorUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.util.UUID
@Suppress("ClassName")
@@ -25,7 +25,7 @@ class ThreadTableTest_recents {
@Before
fun setUp() {
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())))
}
@Test

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().uuid()).build(),
metadata = metadata(
address = address(uuid = messageSender.requireServiceId().uuid()).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()
.setUuid(harness.self.requireServiceId().toByteString())
.setJoinedAtRevision(0)
.setRole(Member.Role.DEFAULT)
.build(),
DecryptedMember.newBuilder()
.setUuid(sender.requireServiceId().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

@@ -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)

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

@@ -14,8 +14,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.storage.SignalContactRecord
import org.whispersystems.signalservice.api.storage.StorageId
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord
@@ -39,14 +39,13 @@ class ContactRecordProcessorTest {
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setAci(ACI_A.toString())
setUnregisteredAtTimestamp(100)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
setPni(PNI_A.toString())
setE164(E164_A)
}
// WHEN
@@ -54,10 +53,10 @@ class ContactRecordProcessorTest {
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByServiceId(PNI_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()
assertEquals(originalId, byAci)
assertEquals(byE164, byPni)
@@ -71,14 +70,14 @@ class ContactRecordProcessorTest {
setStorageId(originalId, STORAGE_ID_A)
val remote1 = buildRecord(STORAGE_ID_B) {
setServiceId(ACI_A.toString())
setAci(ACI_A.toString())
setUnregisteredAtTimestamp(0)
}
val remote2 = buildRecord(STORAGE_ID_C) {
setServiceId(PNI_A.toString())
setServicePni(PNI_A.toString())
setServiceE164(E164_A)
setAci(PNI_A.toString())
setPni(PNI_A.toString())
setE164(E164_A)
}
// WHEN
@@ -86,7 +85,7 @@ class ContactRecordProcessorTest {
subject.process(listOf(remote1, remote2), StorageSyncHelper.KEY_GENERATOR)
// THEN
val byAci: RecipientId = SignalDatabase.recipients.getByServiceId(ACI_A).get()
val byAci: RecipientId = SignalDatabase.recipients.getByAci(ACI_A).get()
val byE164: RecipientId = SignalDatabase.recipients.getByE164(E164_A).get()
val byPni: RecipientId = SignalDatabase.recipients.getByPni(PNI_A).get()

View File

@@ -27,7 +27,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
private val aliceSenderCertificate = FakeClientHelpers.createCertificateFor(
trustRoot = trustRoot,
uuid = serviceId.uuid(),
uuid = serviceId.rawUuid,
e164 = e164,
deviceId = 1,
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,

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.
@@ -50,7 +50,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
private val serviceAddress = SignalServiceAddress(serviceId, e164)
private val registrationId = KeyHelper.generateRegistrationId(false)
private val aciStore = BobSignalServiceAccountDataStore(registrationId, identityKeyPair)
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.uuid(), e164, 1, identityKeyPair.publicKey.publicKey, 31337)
private val senderCertificate = FakeClientHelpers.createCertificateFor(trustRoot, serviceId.rawUuid, e164, 1, identityKeyPair.publicKey.publicKey, 31337)
private val sessionLock = object : SignalSessionLock {
private val lock = ReentrantLock()
@@ -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

@@ -8,16 +8,16 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import kotlin.random.Random
/**
* Helper methods for creating groups for message processing tests et al.
*/
object GroupTestingUtils {
fun member(serviceId: ServiceId, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
fun member(aci: ACI, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
return DecryptedMember.newBuilder()
.setUuid(serviceId.toByteString())
.setAciBytes(aci.toByteString())
.setJoinedAtRevision(revision)
.setRole(role)
.build()
@@ -43,7 +43,7 @@ object GroupTestingUtils {
}
fun Recipient.asMember(): DecryptedMember {
return member(serviceId = requireServiceId())
return member(aci = requireAci())
}
data class TestGroupInfo(val groupId: GroupId.V2, val masterKey: GroupMasterKey, val recipientId: RecipientId)

View File

@@ -32,7 +32,7 @@ import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor

View File

@@ -4,8 +4,8 @@ import org.junit.rules.TestWatcher
import org.junit.runner.Description
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.UUID
/**

View File

@@ -2,7 +2,7 @@ package org.thoughtcrime.securesms.testing
import com.google.protobuf.ByteString
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
@@ -17,7 +17,7 @@ class TestProtos private constructor() {
uuid: UUID = UUID.randomUUID()
): AddressProto.Builder {
return AddressProto.newBuilder()
.setUuid(ServiceId.from(uuid).toByteString())
.setUuid(ACI.from(uuid).toByteString())
}
fun metadata(

View File

@@ -7,8 +7,8 @@ import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.account.PreKeyUpload
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import java.io.IOException
import java.util.Optional

View File

@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.registration.RegistrationUtil
import org.thoughtcrime.securesms.registration.VerifyResponse
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.ServiceResponse
import org.whispersystems.signalservice.internal.ServiceResponseProcessor

View File

@@ -630,22 +630,11 @@
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".conversation.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:parentActivityName=".MainActivity"
android:exported="false">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".conversation.BubbleConversationActivity"
android:theme="@style/Signal.DayNight"
android:allowEmbedded="true"
android:resizeableActivity="true"
android:exported="false"/>
android:theme="@style/Signal.DayNight"
android:allowEmbedded="true"
android:resizeableActivity="true"
android:exported="false"/>
<activity android:name=".conversation.ConversationPopupActivity"
android:windowSoftInputMode="stateVisible"
@@ -1145,9 +1134,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>
@@ -1334,7 +1325,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

@@ -6,7 +6,6 @@ import androidx.annotation.NonNull;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.insights.InsightsOptOut;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob;
@@ -30,7 +29,6 @@ public final class AppInitialization {
public static void onFirstEverAppLaunch(@NonNull Context context) {
Log.i(TAG, "onFirstEverAppLaunch()");
InsightsOptOut.userRequestedOptOut(context);
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());
@@ -71,7 +69,6 @@ public final class AppInitialization {
public static void onRepairFirstEverAppLaunch(@NonNull Context context) {
Log.w(TAG, "onRepairFirstEverAppLaunch()");
InsightsOptOut.userRequestedOptOut(context);
TextSecurePreferences.setAppMigrationVersion(context, ApplicationMigrations.CURRENT_VERSION);
TextSecurePreferences.setJobManagerVersion(context, JobManager.CURRENT_VERSION);
TextSecurePreferences.setLastVersionCode(context, Util.getCanonicalVersionCode());

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

@@ -254,13 +254,8 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
for (SelectedContact contact : contacts) {
RecipientId recipientId = contact.getOrCreateRecipientId(context);
Recipient recipient = Recipient.resolved(recipientId);
int subscriptionId = recipient.getDefaultSubscriptionId().orElse(-1);
MessageSender.send(context, OutgoingMessage.sms(recipient, message, subscriptionId), -1L, MessageSender.SendType.SMS, null, null);
if (recipient.getContactUri() != null) {
SignalDatabase.recipients().setHasSentInvite(recipient.getId());
}
MessageSender.send(context, OutgoingMessage.sms(recipient, message), -1L, MessageSender.SendType.SMS, null, null);
}
return null;

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,12 +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.SlowNotificationsViewModel;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
import org.thoughtcrime.securesms.util.AppStartup;
@@ -40,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;
@@ -74,6 +82,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
});
lifecycleDisposable.bindTo(this);
mediaController = new VoiceNoteMediaController(this, true);
@@ -89,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
@@ -136,6 +167,8 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
updateTabVisibility();
slowNotificationsViewModel.checkSlowNotificationHeuristics();
}
@Override

View File

@@ -11,7 +11,6 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.recipients.RecipientId;
import io.reactivex.rxjava3.disposables.Disposable;
@@ -78,10 +77,6 @@ public class MainNavigator {
activity.startActivity(intent);
}
public void goToInsights() {
InsightsLauncher.showInsightsDashboard(activity.getSupportFragmentManager());
}
private @NonNull FragmentManager getFragmentManager() {
return activity.getSupportFragmentManager();
}

View File

@@ -371,6 +371,7 @@ public class NewConversationActivity extends ContactSelectionActivity
.setPositiveButton(R.string.NewConversationActivity__remove,
(dialog, which) -> {
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
onRefresh();
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
}));
}

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

@@ -37,6 +37,7 @@ import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.ViewModelProvider;
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter;
import androidx.window.layout.DisplayFeature;
@@ -50,6 +51,7 @@ import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
@@ -61,6 +63,10 @@ import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
import org.thoughtcrime.securesms.components.webrtc.InCallStatus;
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet;
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice;
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
@@ -76,8 +82,10 @@ import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
import org.thoughtcrime.securesms.service.webrtc.CallLinkDisconnectReason;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
@@ -93,8 +101,10 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.disposables.Disposable;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
@@ -109,7 +119,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
/**
* ANSWER the call via voice-only.
*/
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
/**
* ANSWER the call via video.
@@ -120,6 +130,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK";
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
@@ -136,6 +147,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter;
private ThrottledDebouncer requestNewSizesThrottle;
private PictureInPictureParams.Builder pipBuilderParams;
private LifecycleDisposable lifecycleDisposable;
private long lastCallLinkDisconnectDialogShowTime;
private Disposable ephemeralStateDisposable = Disposable.empty();
@@ -149,6 +162,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
lifecycleDisposable = new LifecycleDisposable();
lifecycleDisposable.bindTo(this);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
@@ -188,6 +205,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
initializePendingParticipantFragmentListener();
}
@Override
@@ -239,7 +258,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.onPause();
if (!viewModel.isCallStarting()) {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
finish();
}
@@ -259,7 +278,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
if (!viewModel.isCallStarting()) {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
}
@@ -334,6 +353,54 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private void initializePendingParticipantFragmentListener() {
if (!FeatureFlags.adHocCalling()) {
return;
}
getSupportFragmentManager().setFragmentResultListener(
PendingParticipantsBottomSheet.REQUEST_KEY,
this,
(requestKey, result) -> {
PendingParticipantsBottomSheet.Action action = PendingParticipantsBottomSheet.getAction(result);
List<RecipientId> recipientIds = viewModel.getPendingParticipantsSnapshot()
.getUnresolvedPendingParticipants()
.stream()
.map(r -> r.getRecipient().getId())
.collect(Collectors.toList());
switch (action) {
case NONE:
break;
case APPROVE_ALL:
new MaterialAlertDialogBuilder(this)
.setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__approve_d_requests, recipientIds.size(), recipientIds.size()))
.setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size()))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.WebRtcCallActivity__approve_all, (dialog, which) -> {
for (RecipientId id : recipientIds) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(id);
}
})
.show();
break;
case DENY_ALL:
new MaterialAlertDialogBuilder(this)
.setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__deny_d_requests, recipientIds.size(), recipientIds.size()))
.setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size()))
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.WebRtcCallActivity__deny_all, (dialog, which) -> {
for (RecipientId id : recipientIds) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(id);
}
})
.show();
break;
}
}
);
}
private void initializeScreenshotSecurity() {
if (TextSecurePreferences.isScreenSecurityEnabled(this)) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
@@ -363,21 +430,23 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getEvents().observe(this, this::handleViewModelEvent);
viewModel.getCallTime().observe(this, this::handleCallTime);
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
viewModel.getOrientationAndLandscapeEnabled(),
viewModel.getEphemeralState(),
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink))
.observe(this, p -> callScreen.updateCallParticipants(p));
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
lifecycleDisposable.add(viewModel.shouldShowSpeakerHint().subscribe(this::updateSpeakerHint));
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null) {
if (state.needsNewRequestSizes()) {
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
@@ -397,6 +466,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
});
callScreen.setPendingParticipantsViewListener(new PendingParticipantsViewListener());
Disposable disposable = viewModel.getPendingParticipants()
.subscribe(callScreen::updatePendingParticipantsList);
lifecycleDisposable.add(disposable);
}
private void initializePictureInPictureParams() {
@@ -458,14 +533,35 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private void handleCallTime(long callTime) {
EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime);
private void handleInCallStatus(@NonNull InCallStatus inCallStatus) {
if (inCallStatus instanceof InCallStatus.ElapsedTime) {
if (ellapsedTimeFormatter == null) {
return;
EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(((InCallStatus.ElapsedTime) inCallStatus).getElapsedTime());
if (ellapsedTimeFormatter == null) {
return;
}
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
} else if (inCallStatus instanceof InCallStatus.PendingCallLinkUsers) {
int waiting = ((InCallStatus.PendingCallLinkUsers) inCallStatus).getPendingUserCount();
callScreen.setStatus(getResources().getQuantityString(
R.plurals.WebRtcCallActivity__d_people_waiting,
waiting,
waiting
));
} else if (inCallStatus instanceof InCallStatus.JoinedCallLinkUsers) {
int joined = ((InCallStatus.JoinedCallLinkUsers) inCallStatus).getJoinedUserCount();
callScreen.setStatus(getResources().getQuantityString(
R.plurals.WebRtcCallActivity__d_people,
joined,
joined
));
}else {
throw new AssertionError();
}
callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString()));
}
private void handleSetAudioHandset() {
@@ -672,7 +768,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onSendAnywayAfterSafetyNumberChange(@NonNull List<RecipientId> changedRecipients) {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state == null) {
return;
@@ -686,11 +782,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
@Override
public void onMessageResentAfterSafetyNumberChange() { }
public void onMessageResentAfterSafetyNumberChange() {}
@Override
public void onCanceled() {
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getGroupCallState().isNotIdle()) {
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
ApplicationDependencies.getSignalCallManager().cancelPreJoin();
@@ -758,6 +854,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
handleUntrustedIdentity(event); break;
}
if (event.getCallLinkDisconnectReason() != null && event.getCallLinkDisconnectReason().getPostedAt() > lastCallLinkDisconnectDialogShowTime) {
lastCallLinkDisconnectDialogShowTime = System.currentTimeMillis();
if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.RemovedFromCall) {
displayRemovedFromCallLinkDialog();
} else if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.DeniedRequestToJoinCall) {
displayDeniedRequestToJoinCallLinkDialog();
} else {
throw new AssertionError("Unexpected reason: " + event.getCallLinkDisconnectReason());
}
}
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
viewModel.updateFromWebRtcViewModel(event, enableVideo);
@@ -779,6 +887,22 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private void displayRemovedFromCallLinkDialog() {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.WebRtcCallActivity__removed_from_call)
.setMessage(R.string.WebRtcCallActivity__someone_has_removed_you_from_the_call)
.setPositiveButton(android.R.string.ok, null)
.show();
}
private void displayDeniedRequestToJoinCallLinkDialog() {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.WebRtcCallActivity__join_request_denied)
.setMessage(R.string.WebRtcCallActivity__your_request_to_join_this_call_has_been_denied)
.setPositiveButton(android.R.string.ok, null)
.show();
}
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
@@ -833,6 +957,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) {
maybeDisplaySpeakerphonePopup(audioOutput);
switch (audioOutput) {
case HANDSET:
handleSetAudioHandset();
@@ -853,8 +978,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@RequiresApi(31)
@Override
public void onAudioOutputChanged31(@NonNull Integer audioDeviceInfo) {
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioDeviceInfo));
public void onAudioOutputChanged31(@NonNull WebRtcAudioDevice audioOutput) {
maybeDisplaySpeakerphonePopup(audioOutput.getWebRtcAudioOutput());
ApplicationDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioOutput.getDeviceId()));
}
@Override
@@ -937,6 +1063,33 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private void maybeDisplaySpeakerphonePopup(WebRtcAudioOutput nextOutput) {
final WebRtcAudioOutput currentOutput = viewModel.getCurrentAudioOutput();
if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) {
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_OFF);
} else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) {
callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_ON);
}
}
private class PendingParticipantsViewListener implements PendingParticipantsView.Listener {
@Override
public void onAllowPendingRecipient(@NonNull Recipient pendingRecipient) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient.getId());
}
@Override
public void onRejectPendingRecipient(@NonNull Recipient pendingRecipient) {
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient.getId());
}
@Override
public void onLaunchPendingRequestsSheet() {
new PendingParticipantsBottomSheet().show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
}
}
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
@Override

View File

@@ -64,11 +64,21 @@ public class AudioRecorder {
}
public @NonNull Single<VoiceNoteDraft> startRecording() {
Log.i(TAG, "startRecording()");
return startRecording(Build.VERSION.SDK_INT >= 26);
}
public @NonNull Single<VoiceNoteDraft> startRecording(final boolean useMediaCodecWrapper) {
Log.i(TAG, "startRecording(" + useMediaCodecWrapper + ")");
final SingleSubject<VoiceNoteDraft> recordingSingle = SingleSubject.create();
startRecordingInternal(useMediaCodecWrapper, recordingSingle);
return recordingSingle;
}
private void startRecordingInternal(boolean useMediaRecorderWrapper, SingleSubject<VoiceNoteDraft> recordingSingle) {
executor.execute(() -> {
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
Log.i(TAG, "Running startRecording(" + useMediaRecorderWrapper + ") + " + Thread.currentThread().getId());
try {
if (recorder != null) {
recordingSingle.onError(new IllegalStateException("We can only do one recording at a time!"));
@@ -82,7 +92,7 @@ public class AudioRecorder {
.withMimeType(MediaUtil.AUDIO_AAC)
.createForDraftAttachmentAsync(context);
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
recorder = useMediaRecorderWrapper ? new MediaRecorderWrapper() : new AudioCodec();
int focusResult = audioFocusManager.requestAudioFocus();
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
@@ -90,13 +100,17 @@ public class AudioRecorder {
recorder.start(fds[1]);
this.recordingSubject = recordingSingle;
} catch (IOException | RuntimeException e) {
recordingSingle.onError(e);
recorder = null;
Log.w(TAG, e);
recordingUriFuture = null;
recorder = null;
audioFocusManager.abandonAudioFocus();
if (useMediaRecorderWrapper) {
startRecordingInternal(false, recordingSingle);
} else {
recordingSingle.onError(e);
}
}
});
return recordingSingle;
}
public void stopRecording() {

View File

@@ -41,7 +41,7 @@ public class MediaRecorderWrapper implements Recorder {
recorder.setAudioChannels(CHANNELS);
recorder.prepare();
recorder.start();
} catch (IllegalStateException e) {
} catch (RuntimeException e) {
Log.w(TAG, "Unable to start recording", e);
recorder.release();
recorder = null;

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.FeatureFlags
import java.net.URLDecoder
/**
@@ -53,6 +54,10 @@ object CallLinks {
@JvmStatic
fun isCallLink(url: String): Boolean {
if (!FeatureFlags.adHocCalling()) {
return false
}
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
Log.w(TAG, "Invalid url prefix.")
return false
@@ -63,6 +68,10 @@ object CallLinks {
@JvmStatic
fun parseUrl(url: String): CallLinkRootKey? {
if (!FeatureFlags.adHocCalling()) {
return null
}
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
Log.w(TAG, "Invalid url prefix.")
return null

View File

@@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -35,7 +36,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkRootKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
import org.thoughtcrime.securesms.database.CallLinkTable
@@ -49,10 +49,10 @@ import java.time.Instant
@Composable
private fun SignalCallRowPreview() {
val callLink = remember {
val credentials = CallLinkCredentials.generate()
val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8))
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)),
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
@@ -76,6 +76,14 @@ fun SignalCallRow(
onJoinClicked: (() -> Unit)?,
modifier: Modifier = Modifier
) {
val callUrl = if (LocalInspectionMode.current) {
"https://signal.call.example.com"
} else {
remember(callLink.credentials) {
callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: ""
}
}
Row(
modifier = modifier
.fillMaxWidth()
@@ -113,7 +121,7 @@ fun SignalCallRow(
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
)
Text(
text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "",
text = callUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)

View File

@@ -10,6 +10,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@@ -58,6 +59,7 @@ class UpdateCallLinkRepository(
return { result ->
if (result is UpdateCallLinkResult.Success) {
SignalDatabase.callLinks.updateCallLinkState(credentials.roomId, result.state)
ApplicationDependencies.getJobManager().add(CallLinkUpdateSendJob(credentials.roomId))
}
}
}

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

@@ -9,6 +9,7 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
@@ -61,6 +62,7 @@ class CreateCallLinkViewModel(
fun commitCallLink(): Single<EnsureCallLinkCreatedResult> {
return repository.ensureCallLinkCreated(credentials)
.observeOn(AndroidSchedulers.mainThread())
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
@@ -74,10 +76,12 @@ class CreateCallLinkViewModel(
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
.observeOn(AndroidSchedulers.mainThread())
}
fun toggleApproveAllMembers(): Single<UpdateCallLinkResult> {
return setApproveAllMembers(_callLink.value.state.restrictions != Restrictions.ADMIN_APPROVAL)
.observeOn(AndroidSchedulers.mainThread())
}
fun setCallName(callName: String): Single<UpdateCallLinkResult> {
@@ -91,5 +95,6 @@ class CreateCallLinkViewModel(
is EnsureCallLinkCreatedResult.Failure -> Single.just(UpdateCallLinkResult.Failure(it.failure.status))
}
}
.observeOn(AndroidSchedulers.mainThread())
}
}

View File

@@ -244,18 +244,20 @@ private fun CallLinkDetails(
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
onClick = callback::onEditNameClicked
)
if (state.callLink.credentials?.adminPassBytes != null) {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
onClick = callback::onEditNameClicked
)
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = callback::onApproveAllMembersChanged
)
Dividers.Default()
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),

View File

@@ -215,7 +215,15 @@ class CallLogAdapter(
binding.callInfo.setRelativeDrawables(start = R.drawable.symbol_link_compact_16)
binding.callInfo.setText(R.string.CallLogAdapter__call_link)
TextViewCompat.setCompoundDrawableTintList(
binding.callInfo,
ColorStateList.valueOf(
ContextCompat.getColor(context, R.color.signal_colorOnSurfaceVariant)
)
)
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient)
}
@@ -306,6 +314,7 @@ class CallLogAdapter(
when (model.call.record.type) {
CallTable.Type.AUDIO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_phone_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_voice_call)
binding.callType.setOnClickListener { onStartAudioCallClicked(model.call.peer) }
binding.callType.visible = true
binding.groupCallButton.visible = false
@@ -313,6 +322,7 @@ class CallLogAdapter(
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.callType.visible = true
binding.groupCallButton.visible = false
@@ -320,6 +330,7 @@ class CallLogAdapter(
CallTable.Type.GROUP_CALL, CallTable.Type.AD_HOC_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
binding.groupCallButton.setOnClickListener { onStartVideoCallClicked(model.call.peer) }
@@ -354,7 +365,7 @@ class CallLogAdapter(
MessageTypes.OUTGOING_AUDIO_CALL_TYPE, MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_upright_compact_16
MessageTypes.GROUP_CALL_TYPE -> when {
call.type == CallTable.Type.AD_HOC_CALL -> R.drawable.symbol_link_compact_16
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_24
call.event == CallTable.Event.MISSED -> R.drawable.symbol_missed_incoming_compact_16
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.drawable.symbol_group_compact_16
call.direction == CallTable.Direction.INCOMING -> R.drawable.symbol_arrow_downleft_compact_16
call.direction == CallTable.Direction.OUTGOING -> R.drawable.symbol_arrow_upright_compact_16

View File

@@ -5,11 +5,14 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.CallLinkPeekJob
import org.thoughtcrime.securesms.jobs.CallLogEventSendJob
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@@ -80,6 +83,23 @@ class CallLogRepository(
}.subscribeOn(Schedulers.io())
}
/**
* Delete all call events / unowned links and enqueue clear history job, and then
* emit a clear history message.
*/
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
return Single.fromCallable {
SignalDatabase.rawDatabase.withinTransaction {
val now = System.currentTimeMillis()
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(now)
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(now)
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(now))
}
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
}.flatMap(this::revokeAndCollectResults).map { -1 }.subscribeOn(Schedulers.io())
}
/**
* Deletes the selected call links. We DELETE those links we don't have admin keys for,
* and revoke the ones we *do* have admin keys for. We then perform a cleanup step on
@@ -93,19 +113,7 @@ class CallLogRepository(
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteNonAdminCallLinks(allCallLinkIds)
SignalDatabase.callLinks.getAdminCallLinks(allCallLinkIds)
}.flatMap { callLinksToRevoke ->
Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
}
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.subscribeOn(Schedulers.io())
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
}
/**
@@ -121,19 +129,21 @@ class CallLogRepository(
val allCallLinkIds = SignalDatabase.calls.getCallLinkRoomIdsFromCallRowIds(selectedCallRowIds) + selectedRoomIds
SignalDatabase.callLinks.deleteAllNonAdminCallLinksExcept(allCallLinkIds)
SignalDatabase.callLinks.getAllAdminCallLinksExcept(allCallLinkIds)
}.flatMap { callLinksToRevoke ->
Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
}.flatMap(this::revokeAndCollectResults).subscribeOn(Schedulers.io())
}
private fun revokeAndCollectResults(callLinksToRevoke: Set<CallLinkTable.CallLink>): Single<Int> {
return Single.merge(
callLinksToRevoke.map {
updateCallLinkRepository.revokeCallLink(it.credentials!!)
}
).reduce(0) { acc, current ->
acc + (if (current is UpdateCallLinkResult.Success) 0 else 1)
}.doOnTerminate {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.doOnDispose {
SignalDatabase.calls.updateAdHocCallEventDeletionTimestamps()
}.subscribeOn(Schedulers.io())
}
}
fun peekCallLinks(): Completable {

View File

@@ -93,7 +93,7 @@ sealed class CallLogRow {
return FULL
}
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireServiceId().uuid().toString())) {
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireAci().rawUuid.toString())) {
return LOCAL_USER_JOINED
}

View File

@@ -3,17 +3,17 @@ package org.thoughtcrime.securesms.calls.log
/**
* Selection state object for call logs.
*/
sealed class CallLogSelectionState {
abstract fun contains(callId: CallLogRow.Id): Boolean
abstract fun isNotEmpty(totalCount: Int): Boolean
sealed interface CallLogSelectionState {
fun contains(callId: CallLogRow.Id): Boolean
fun isNotEmpty(totalCount: Int): Boolean
abstract fun count(totalCount: Int): Int
fun count(totalCount: Int): Int
abstract fun selected(): Set<CallLogRow.Id>
fun selected(): Set<CallLogRow.Id>
fun isExclusionary(): Boolean = this is Excludes
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
fun select(callId: CallLogRow.Id): CallLogSelectionState
fun deselect(callId: CallLogRow.Id): CallLogSelectionState
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
return if (contains(callId)) {
@@ -26,7 +26,7 @@ sealed class CallLogSelectionState {
/**
* Includes contains an opt-in list of call logs.
*/
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState {
override fun contains(callId: CallLogRow.Id): Boolean {
return includes.contains(callId)
}
@@ -55,7 +55,7 @@ sealed class CallLogSelectionState {
/**
* Excludes contains an opt-out list of call logs.
*/
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState {
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
@@ -74,8 +74,10 @@ sealed class CallLogSelectionState {
override fun selected(): Set<CallLogRow.Id> = excluded
}
object All : CallLogSelectionState by Excludes(emptySet())
companion object {
fun empty(): CallLogSelectionState = Includes(emptySet())
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
fun selectAll(): CallLogSelectionState = All
}
}

View File

@@ -35,14 +35,21 @@ class CallLogStagedDeletion(
.map { it.roomId }
.toSet()
return if (stateSnapshot.isExclusionary()) {
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen(
repository.deleteAllCallLinksExcept(callRowIds, callLinkIds)
)
} else {
repository.deleteSelectedCallLogs(callRowIds).andThen(
repository.deleteSelectedCallLinks(callRowIds, callLinkIds)
)
return when {
stateSnapshot is CallLogSelectionState.All && filter == CallLogFilter.ALL -> {
repository.deleteAllCallLogsOnOrBeforeNow()
}
stateSnapshot is CallLogSelectionState.Excludes || stateSnapshot is CallLogSelectionState.All -> {
repository.deleteAllCallLogsExcept(callRowIds, filter == CallLogFilter.MISSED).andThen(
repository.deleteAllCallLinksExcept(callRowIds, callLinkIds)
)
}
stateSnapshot is CallLogSelectionState.Includes -> {
repository.deleteSelectedCallLogs(callRowIds).andThen(
repository.deleteSelectedCallLinks(callRowIds, callLinkIds)
)
}
else -> error("Unhandled state $stateSnapshot $filter")
}
}
}

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

@@ -9,13 +9,11 @@ import android.text.Annotation;
import android.text.Editable;
import android.text.InputType;
import android.text.Selection;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import android.view.ActionMode;
import android.view.Menu;
@@ -49,7 +47,6 @@ import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.List;
@@ -65,7 +62,6 @@ public class ComposeText extends EmojiEditText {
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
private CharSequence hint;
private SpannableString subHint;
private MentionRendererDelegate mentionRendererDelegate;
private SpoilerRendererDelegate spoilerRendererDelegate;
private MentionValidatorWatcher mentionValidatorWatcher;
@@ -106,13 +102,7 @@ public class ComposeText extends EmojiEditText {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (getLayout() != null && !TextUtils.isEmpty(hint)) {
if (!TextUtils.isEmpty(subHint)) {
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(hint))
.append("\n")
.append(ellipsizeToWidth(subHint)));
} else {
setHintWithChecks(ellipsizeToWidth(hint));
}
setHintWithChecks(ellipsizeToWidth(hint));
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
@@ -173,25 +163,9 @@ public class ComposeText extends EmojiEditText {
TruncateAt.END);
}
public void setHint(@NonNull String hint, @Nullable CharSequence subHint) {
public void setHint(@NonNull String hint) {
this.hint = hint;
if (subHint != null) {
this.subHint = new SpannableString(subHint);
this.subHint.setSpan(new RelativeSizeSpan(0.5f), 0, subHint.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
} else {
this.subHint = null;
}
if (this.subHint != null) {
setHintWithChecks(new SpannableStringBuilder().append(ellipsizeToWidth(this.hint))
.append("\n")
.append(ellipsizeToWidth(this.subHint)));
} else {
setHintWithChecks(ellipsizeToWidth(this.hint));
}
setHintWithChecks(hint);
setHintWithChecks(ellipsizeToWidth(this.hint));
}
public void setDraftText(@Nullable CharSequence draftText) {
@@ -249,10 +223,7 @@ public class ComposeText extends EmojiEditText {
}
setImeOptions(imeOptions);
setHint(getContext().getString(messageSendType.getComposeHintRes()),
messageSendType.getSimName() != null
? getContext().getString(R.string.conversation_activity__from_sim_name, messageSendType.getSimName())
: null);
setHint(getContext().getString(messageSendType.getComposeHintRes()));
setInputType(inputType);
}

View File

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

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.ViewModelProvider
import org.signal.core.util.ResourceUtil
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.databinding.PromptLogsBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.SupportEmailUtil
class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
companion object {
@JvmStatic
fun show(context: Context, fragmentManager: FragmentManager) {
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
DebugLogsPromptDialogFragment().apply {
arguments = bundleOf()
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()
}
}
}
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private val binding by ViewBinderDelegate(PromptLogsBottomSheetBinding::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_logs_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
disposables.bindTo(viewLifecycleOwner)
viewModel = ViewModelProvider(this).get(PromptLogsViewModel::class.java)
binding.submit.setOnClickListener {
val progressDialog = SignalProgressDialog.show(requireContext())
disposables += viewModel.submitLogs().subscribe({ result ->
submitLogs(result)
progressDialog.dismiss()
dismiss()
}, { _ ->
Toast.makeText(requireContext(), getString(R.string.HelpFragment__could_not_upload_logs), Toast.LENGTH_LONG).show()
progressDialog.dismiss()
dismiss()
})
}
binding.decline.setOnClickListener {
SignalStore.uiHints().markDeclinedShareNotificationLogs()
dismiss()
}
}
private fun submitLogs(debugLog: String) {
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(R.string.DebugLogsPromptDialogFragment__signal_android_support_request),
getEmailBody(debugLog)
)
}
private fun getEmailBody(debugLog: String?): String {
val suffix = StringBuilder()
if (debugLog != null) {
suffix.append("\n")
suffix.append(getString(R.string.HelpFragment__debug_log))
suffix.append(" ")
suffix.append(debugLog)
}
val category = ResourceUtil.getEnglishResources(requireContext()).getString(R.string.DebugLogsPromptDialogFragment__slow_notifications_category)
return SupportEmailUtil.generateSupportEmailBody(
requireContext(),
R.string.DebugLogsPromptDialogFragment__signal_android_support_request,
" - $category",
"\n\n",
suffix.toString()
)
}
}

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

@@ -48,9 +48,13 @@ public class FromTextView extends SimpleEmojiTextView {
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix) {
setText(recipient, fromString, read, suffix, true);
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, boolean read, @Nullable String suffix, boolean asThread) {
SpannableStringBuilder builder = new SpannableStringBuilder();
if (recipient.isSelf()) {
if (asThread && recipient.isSelf()) {
builder.append(getContext().getString(R.string.note_to_self));
} else {
builder.append(fromString);
@@ -60,7 +64,7 @@ public class FromTextView extends SimpleEmojiTextView {
builder.append(suffix);
}
if (recipient.showVerified()) {
if (asThread && recipient.showVerified()) {
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));

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

@@ -27,6 +27,7 @@ import androidx.annotation.DimenRes;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.Observer;
import androidx.recyclerview.widget.LinearLayoutManager;
@@ -78,7 +79,7 @@ import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class InputPanel extends LinearLayout
public class InputPanel extends ConstraintLayout
implements AudioRecordingHandler,
KeyboardAwareLinearLayout.OnKeyboardShownListener,
EmojiEventListener,
@@ -101,10 +102,10 @@ public class InputPanel extends LinearLayout
private SendButton sendButton;
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
private View composeContainer;
private View editMessageCancel;
private ImageView editMessageThumbnail;
private View editMessageHeader;
private View editMessageTitle;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -163,7 +164,7 @@ public class InputPanel extends LinearLayout
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction(false));
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
this.editMessageHeader = findViewById(R.id.edit_message_compose_header);
this.editMessageTitle = findViewById(R.id.edit_message_title);
this.editMessageThumbnail = findViewById(R.id.edit_message_thumbnail);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
@@ -454,13 +455,15 @@ public class InputPanel extends LinearLayout
private void updateEditModeUi() {
if (inEditMessageMode()) {
ViewUtil.focusAndShowKeyboard(composeText);
editMessageHeader.setVisibility(View.VISIBLE);
editMessageTitle.setVisibility(View.VISIBLE);
editMessageThumbnail.setVisibility(View.VISIBLE);
editMessageCancel.setVisibility(View.VISIBLE);
if (listener != null) {
listener.onEnterEditMode();
}
} else {
editMessageHeader.setVisibility(View.GONE);
editMessageTitle.setVisibility(View.GONE);
editMessageThumbnail.setVisibility(View.GONE);
editMessageCancel.setVisibility(View.GONE);
if (listener != null) {
listener.onExitEditMode();

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

@@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.SingleSubject
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
class PromptLogsViewModel : ViewModel() {
private val submitDebugLogRepository = SubmitDebugLogRepository()
fun submitLogs(): Single<String> {
val singleSubject = SingleSubject.create<String?>()
submitDebugLogRepository.buildAndSubmitLog { result ->
if (result.isPresent) {
singleSubject.onSuccess(result.get())
} else {
singleSubject.onError(Throwable())
}
}
return singleSubject.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread())
}
}

View File

@@ -6,16 +6,8 @@ import android.view.View
import android.view.View.OnLongClickListener
import android.view.ViewGroup
import androidx.appcompat.widget.AppCompatImageButton
import com.google.android.material.snackbar.Snackbar
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.ViewUtil
import java.lang.AssertionError
import java.util.concurrent.CopyOnWriteArrayList
/**
* The send button you see in a conversation.
@@ -23,122 +15,21 @@ import java.util.concurrent.CopyOnWriteArrayList
*/
class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImageButton(context, attributeSet), OnLongClickListener {
companion object {
private val TAG = Log.tag(SendButton::class.java)
}
private val listeners: MutableList<SendTypeChangedListener> = CopyOnWriteArrayList()
private var scheduledSendListener: ScheduledSendListener? = null
private var availableSendTypes: List<MessageSendType> = MessageSendType.getAllAvailable(context, false)
private var activeMessageSendType: MessageSendType? = null
private var defaultTransportType: MessageSendType.TransportType = MessageSendType.TransportType.SIGNAL
private var defaultSubscriptionId: Int? = null
var snackbarContainer: View? = null
private var popupContainer: ViewGroup? = null
init {
setOnLongClickListener(this)
ViewUtil.mirrorIfRtl(this, getContext())
}
/**
* @return True if the [selectedSendType] was chosen manually by the user, otherwise false.
*/
val isManualSelection: Boolean
get() = activeMessageSendType != null
/**
* The actively-selected send type.
*/
val selectedSendType: MessageSendType
get() {
activeMessageSendType?.let {
return it
}
if (defaultTransportType === MessageSendType.TransportType.SMS) {
for (type in availableSendTypes) {
if (type.usesSmsTransport && (defaultSubscriptionId == null || type.simSubscriptionId == defaultSubscriptionId)) {
return type
}
}
}
for (type in availableSendTypes) {
if (type.transportType === defaultTransportType) {
return type
}
}
Log.w(TAG, "No options of default type! Resetting. DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
val signalType: MessageSendType? = availableSendTypes.firstOrNull { it.usesSignalTransport }
if (signalType != null) {
Log.w(TAG, "No options of default type, but Signal type is available. Switching. DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
defaultTransportType = MessageSendType.TransportType.SIGNAL
onSelectionChanged(signalType, false)
return signalType
} else if (availableSendTypes.isEmpty()) {
Log.w(TAG, "No send types available at all! Enabling the Signal transport.")
defaultTransportType = MessageSendType.TransportType.SIGNAL
availableSendTypes = listOf(MessageSendType.SignalMessageSendType)
onSelectionChanged(MessageSendType.SignalMessageSendType, false)
return MessageSendType.SignalMessageSendType
} else {
throw AssertionError("No options of default type! DefaultTransportType: $defaultTransportType, AllAvailable: ${availableSendTypes.map { it.transportType }}")
}
}
fun addOnSelectionChangedListener(listener: SendTypeChangedListener) {
listeners.add(listener)
}
fun triggerSelectedChangedEvent() {
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
setImageResource(MessageSendType.SignalMessageSendType.buttonDrawableRes)
contentDescription = context.getString(MessageSendType.SignalMessageSendType.titleRes)
}
fun setScheduledSendListener(listener: ScheduledSendListener?) {
this.scheduledSendListener = listener
}
fun resetAvailableTransports(isMediaMessage: Boolean) {
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
activeMessageSendType = null
defaultTransportType = MessageSendType.TransportType.SIGNAL
defaultSubscriptionId = null
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
}
fun disableTransportType(type: MessageSendType.TransportType) {
availableSendTypes = availableSendTypes.filterNot { it.transportType == type }
}
fun setDefaultTransport(type: MessageSendType.TransportType) {
if (defaultTransportType == type) {
return
}
defaultTransportType = type
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
}
fun setSendType(sendType: MessageSendType?) {
if (activeMessageSendType == sendType) {
return
}
activeMessageSendType = sendType
onSelectionChanged(newType = selectedSendType, isManualSelection = true)
}
fun setDefaultSubscriptionId(subscriptionId: Int?) {
if (defaultSubscriptionId == subscriptionId) {
return
}
defaultSubscriptionId = subscriptionId
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
}
/**
* Must be called with a view that is acceptable for determining the bounds of the popup selector.
*/
@@ -146,78 +37,19 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
popupContainer = container
}
private fun onSelectionChanged(newType: MessageSendType, isManualSelection: Boolean) {
setImageResource(newType.buttonDrawableRes)
contentDescription = context.getString(newType.titleRes)
for (listener in listeners) {
listener.onSendTypeChanged(newType, isManualSelection)
}
}
fun showSendTypeMenu(): Boolean {
return if (availableSendTypes.size == 1) {
if (scheduledSendListener == null && snackbarContainer != null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
Snackbar.make(snackbarContainer!!, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
}
false
} else {
showSendTypeContextMenu(false)
true
}
}
override fun onLongClick(v: View): Boolean {
if (!isEnabled) {
return false
}
val scheduleListener = scheduledSendListener
if (availableSendTypes.size == 1) {
return if (scheduleListener?.canSchedule() == true && selectedSendType.transportType != MessageSendType.TransportType.SMS) {
scheduleListener.onSendScheduled()
true
} else if (snackbarContainer != null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
Snackbar.make(snackbarContainer!!, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
true
} else {
false
}
return if (scheduleListener?.canSchedule() == true) {
scheduleListener.onSendScheduled()
true
} else {
false
}
showSendTypeContextMenu(selectedSendType.transportType != MessageSendType.TransportType.SMS)
return true
}
private fun showSendTypeContextMenu(allowScheduling: Boolean) {
val currentlySelected: MessageSendType = selectedSendType
val listener = scheduledSendListener
val items = availableSendTypes
.filterNot { it == currentlySelected }
.map { option ->
ActionItem(
iconRes = option.menuDrawableRes,
title = option.getTitle(context),
action = { setSendType(option) }
)
}.toMutableList()
if (allowScheduling && listener?.canSchedule() == true) {
items += ActionItem(
iconRes = R.drawable.symbol_calendar_24,
title = context.getString(R.string.conversation_activity__option_schedule_message),
action = { listener.onSendScheduled() }
)
}
SignalContextMenu.Builder((parent as View), popupContainer!!)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.offsetY(ViewUtil.dpToPx(8))
.show(items)
}
fun interface SendTypeChangedListener {
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
}
interface ScheduledSendListener {

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.emoji;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
@@ -18,6 +19,8 @@ import android.text.method.TransformationMethod;
import android.text.style.CharacterStyle;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.ViewGroup;
import androidx.annotation.ColorInt;
@@ -25,6 +28,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.AppCompatTextView;
import androidx.core.content.ContextCompat;
import androidx.core.view.GestureDetectorCompat;
import androidx.core.view.ViewKt;
import androidx.core.widget.TextViewCompat;
@@ -315,6 +319,12 @@ public class EmojiTextView extends AppCompatTextView {
}
}
@SuppressLint("ClickableViewAccessibility")
public void bindGestureListener() {
GestureDetectorCompat gestureDetectorCompat = new GestureDetectorCompat(getContext(), new OnGestureListener());
setOnTouchListener((v, event) -> gestureDetectorCompat.onTouchEvent(event));
}
private void ellipsizeAnyTextForMaxLength() {
if (maxLength > 0 && getText().length() > maxLength + 1) {
SpannableStringBuilder newContent = new SpannableStringBuilder();
@@ -465,4 +475,32 @@ public class EmojiTextView extends AppCompatTextView {
mentionRendererDelegate.setTint(mentionBackgroundTint);
}
}
/**
* Due to some peculiarities in how TextView deals with touch events, it's really easy to accidentally trigger
* a click (say, when you try to scroll but you're at the bottom of a view.) Because of this, we handle these
* events manually.
*/
private class OnGestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onDown(@NonNull MotionEvent e) {
return true;
}
@Override
public boolean onScroll(@NonNull MotionEvent e1, @NonNull MotionEvent e2, float distanceX, float distanceY) {
if (!canScrollVertically((int) distanceY)) {
return true;
}
int maxScrollDistance = computeVerticalScrollRange() - computeHorizontalScrollExtent();
scrollTo(0, Util.clamp(getScrollY() + (int) distanceY, 0, maxScrollDistance));
return true;
}
@Override
public boolean onSingleTapConfirmed(@NonNull MotionEvent e) {
return performClick();
}
}
}

View File

@@ -14,6 +14,7 @@ import androidx.annotation.RequiresApi;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.PowerManagerCompat;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@SuppressLint("BatteryLife")
@@ -25,9 +26,7 @@ public class DozeReminder extends Reminder {
setOkListener(v -> {
TextSecurePreferences.setPromptedOptimizeDoze(context, true);
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
PowerManagerCompat.requestIgnoreBatteryOptimizations(context);
});
setDismissListener(v -> TextSecurePreferences.setPromptedOptimizeDoze(context, true));

View File

@@ -1,25 +0,0 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
public final class FirstInviteReminder extends Reminder {
private final int percentIncrease;
public FirstInviteReminder(final int percentIncrease) {
super(R.string.FirstInviteReminder__title, NO_RESOURCE);
this.percentIncrease = percentIncrease;
addAction(new Action(R.string.InsightsReminder__invite, R.id.reminder_action_invite));
addAction(new Action(R.string.InsightsReminder__view_insights, R.id.reminder_action_view_insights));
}
@Override
public @NonNull CharSequence getText(@NonNull Context context) {
return context.getString(R.string.FirstInviteReminder__description, percentIncrease);
}
}

View File

@@ -1,37 +0,0 @@
package org.thoughtcrime.securesms.components.reminder;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
public final class SecondInviteReminder extends Reminder {
private final Recipient recipient;
private final int progress;
public SecondInviteReminder(final @NonNull Context context,
final @NonNull Recipient recipient,
final int percent)
{
super(R.string.SecondInviteReminder__title, NO_RESOURCE);
this.recipient = recipient;
this.progress = percent;
addAction(new Action(R.string.InsightsReminder__invite, R.id.reminder_action_invite));
addAction(new Action(R.string.InsightsReminder__view_insights, R.id.reminder_action_view_insights));
}
@Override
public @NonNull CharSequence getText(@NonNull Context context) {
return context.getString(R.string.SecondInviteReminder__description, recipient.getDisplayName(context));
}
@Override
public int getProgress() {
return progress;
}
}

View File

@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.Objects
private val TAG: String = Log.tag(ChangeNumberLockActivity::class.java)

View File

@@ -34,8 +34,7 @@ import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.account.PreKeyUpload
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.ServiceIdType
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
@@ -243,7 +242,7 @@ class ChangeNumberRepository(
throw AssertionError("No change number metadata")
}
val originalPni = ServiceId.fromByteString(metadata.previousPni)
val originalPni = PNI.parseOrThrow(metadata.previousPni)
if (originalPni == pni) {
Log.i(TAG, "No change has occurred, PNI is unchanged: $pni")

View File

@@ -27,7 +27,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.BaseRegistrationViewMod
import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.DefaultValueLiveData
import org.whispersystems.signalservice.api.push.PNI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.exceptions.IncorrectCodeException
import org.whispersystems.signalservice.internal.ServiceResponse
import java.util.Objects

View File

@@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.components.settings.app.internal.search
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
@@ -46,7 +45,7 @@ class InternalSearchViewModel : ViewModel() {
InternalSearchResult(
id = record.id,
name = record.displayName(),
aci = record.serviceId?.toString(),
aci = record.aci?.toString(),
pni = record.pni.toString(),
groupId = record.groupId
)
@@ -70,10 +69,10 @@ class InternalSearchViewModel : ViewModel() {
private fun RecipientRecord.displayName(): String {
return when {
this.groupType == RecipientTable.GroupType.SIGNAL_V1 -> "GV1::${this.groupId}"
this.groupType == RecipientTable.GroupType.SIGNAL_V2 -> "GV2::${this.groupId}"
this.groupType == RecipientTable.GroupType.MMS -> "MMS_GROUP::${this.groupId}"
this.groupType == RecipientTable.GroupType.DISTRIBUTION_LIST -> "DLIST::${this.distributionListId}"
this.recipientType == RecipientTable.RecipientType.GV1 -> "GV1::${this.groupId}"
this.recipientType == RecipientTable.RecipientType.GV2 -> "GV2::${this.groupId}"
this.recipientType == RecipientTable.RecipientType.MMS -> "MMS_GROUP::${this.groupId}"
this.recipientType == RecipientTable.RecipientType.DISTRIBUTION_LIST -> "DLIST::${this.distributionListId}"
this.systemDisplayName?.isNotBlank() == true -> this.systemDisplayName
this.signalProfileName.toString().isNotBlank() -> this.signalProfileName.serialize()
this.e164 != null -> this.e164

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
@@ -184,6 +185,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(

View File

@@ -17,7 +17,8 @@ data class MessageNotificationsState(
val inChatSoundsEnabled: Boolean,
val repeatAlerts: Int,
val messagePrivacy: String,
val priority: Int
val priority: Int,
val troubleshootNotifications: Boolean
)
data class CallNotificationsState(

View File

@@ -8,6 +8,7 @@ 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
@@ -104,7 +105,8 @@ 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 = SlowNotificationHeuristics.isPotentiallyCausedByBatteryOptimizations() && SlowNotificationHeuristics.isHavingDelayedNotifications()
),
callNotificationsState = CallNotificationsState(
notificationsEnabled = SignalStore.settings().isCallNotificationsEnabled,

View File

@@ -25,7 +25,6 @@ import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadin
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.Subscription
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
import org.thoughtcrime.securesms.util.SpanUtil
@@ -245,15 +244,13 @@ class ManageDonationsFragment :
sectionHeaderPref(R.string.ManageDonationsFragment__other_ways_to_give)
if (Recipient.self().giftBadgesCapability == Recipient.Capability.SUPPORTED) {
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_for_a_friend),
icon = DSLSettingsIcon.from(R.drawable.symbol_gift_24),
onClick = {
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__donate_for_a_friend),
icon = DSLSettingsIcon.from(R.drawable.symbol_gift_24),
onClick = {
startActivity(Intent(requireContext(), GiftFlowActivity::class.java))
}
)
}
private fun DSLConfiguration.presentBadges() {

View File

@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.ACI
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
import java.io.IOException

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

@@ -153,8 +153,8 @@ class ConversationSettingsRepository(
if (groupRecord.isV2Group) {
val decryptedGroup: DecryptedGroup = groupRecord.requireV2GroupProperties().decryptedGroup
val pendingMembers: List<RecipientId> = decryptedGroup.pendingMembersList
.map(DecryptedPendingMember::getUuid)
.map(GroupProtoUtil::uuidByteStringToRecipientId)
.map(DecryptedPendingMember::getServiceIdBytes)
.map(GroupProtoUtil::serviceIdBinaryToRecipientId)
val members = mutableListOf<RecipientId>()

View File

@@ -165,8 +165,11 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.hasServiceId()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
if (recipient.hasAci()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireAci().toString())
}
if (recipient.hasPni()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requirePni().toString())
}
}
.show()
@@ -182,14 +185,25 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipient.id))
if (recipient.hasServiceId()) {
SignalDatabase.recipients.debugClearServiceIds(recipient.id)
SignalDatabase.recipients.debugClearProfileData(recipient.id)
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireServiceId().toString())
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requireServiceId().toString())
ApplicationDependencies.getProtocolStore().pni().identities().delete(recipient.requireServiceId().toString())
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipient.id))
}
if (recipient.hasAci()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requireAci().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requirePni(), addressName = recipient.requireAci().toString())
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requireAci().toString())
}
if (recipient.hasPni()) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requireAci(), addressName = recipient.requirePni().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account().requirePni(), addressName = recipient.requirePni().toString())
ApplicationDependencies.getProtocolStore().aci().identities().delete(recipient.requirePni().toString())
}
startActivity(MainActivity.clearTop(requireContext()))
}
.show()
@@ -237,7 +251,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
SignalDatabase.recipients.debugClearE164AndPni(recipient.id)
val splitRecipientId: RecipientId = if (FeatureFlags.phoneNumberPrivacy()) {
SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.pni.orElse(null), recipient.pni.orElse(null), recipient.requireE164())
SignalDatabase.recipients.getAndPossiblyMergePnpVerified(null, recipient.pni.orElse(null), recipient.requireE164())
} else {
SignalDatabase.recipients.getAndPossiblyMerge(recipient.pni.orElse(null), recipient.requireE164())
}
@@ -281,7 +295,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
SignalDatabase.recipients.debugRemoveAci(recipient.id)
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireServiceId(), null, null)
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireAci(), null, null)
recipient.profileKey?.let { profileKey ->
SignalDatabase.recipients.setProfileKey(aciRecipientId, ProfileKey(profileKey))

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,137 +1,104 @@
package org.thoughtcrime.securesms.components.voice;
import android.app.Notification;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Build;
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.checkerframework.checker.units.qual.A;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
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;
import java.util.Collections;
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 SESSION_ID = "VoiceNotePlayback";
private static final String EMPTY_ROOT_ID = "empty-root-id";
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 = new MediaSession.Builder(this, player).setCallback(voiceNotePlayerCallback).setId(SESSION_ID).build();
keyClearedReceiver = new KeyClearedReceiver(this, mediaSession.getToken());
VoiceNotePlaybackController voiceNotePlaybackController = new VoiceNotePlaybackController(player.getInternalPlayer(), voiceNotePlaybackParameters);
mediaSessionConnector.registerCustomCommandReceiver(voiceNotePlaybackController);
setSessionToken(mediaSession.getSessionToken());
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 {
@@ -150,20 +117,14 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
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;
default:
becomingNoisyReceiver.unregister();
voiceNoteNotificationManager.hideNotification();
}
}
@@ -198,7 +159,7 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
currentWindowIndex + LOAD_MORE_THRESHOLD >= player.getMediaItemCount();
if (isWithinThreshold && currentWindowIndex % 2 == 0) {
voiceNotePlaybackPreparer.loadMoreVoiceNotes();
voiceNotePlayerCallback.loadMoreVoiceNotes();
}
}
@@ -217,13 +178,12 @@ public class VoiceNotePlaybackService extends MediaBrowserServiceCompat {
}
Log.i(TAG, "onAudioAttributesChanged: Setting audio stream to " + stream);
mediaSession.setPlaybackToLocal(stream);
}
}
private @Nullable PlaybackParameters getPlaybackParametersForWindowPosition(int currentWindowIndex) {
if (isAudioMessage(currentWindowIndex)) {
return voiceNotePlaybackParameters.getParameters();
return player.getPlaybackParameters();
} else {
return null;
}
@@ -271,49 +231,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 +282,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,321 @@
/*
* 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.MediaMetadata
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.Util
import org.thoughtcrime.securesms.util.hasAudio
import java.util.Objects
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.stream.Collectors
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) {
applyDescriptionsToQueue(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 applyDescriptionsToQueue(mediaItems: List<MediaItem>) {
for (mediaItem in mediaItems) {
val playbackProperties = mediaItem.localConfiguration ?: continue
val holderIndex = indexOfPlayerMediaItemByUri(playbackProperties.uri)
val next = VoiceNoteMediaItemFactory.buildNextVoiceNoteMediaItem(mediaItem)
val currentIndex: Int = player.currentMediaItemIndex
if (holderIndex != -1) {
if (currentIndex != holderIndex) {
player.removeMediaItem(holderIndex)
player.addMediaItem(holderIndex, mediaItem)
}
if (currentIndex != holderIndex + 1) {
if (player.mediaItemCount > 1) {
player.removeMediaItem(holderIndex + 1)
}
player.addMediaItem(holderIndex + 1, next)
}
} else {
val insertLocation = indexAfter(mediaItem)
player.addMediaItem(insertLocation, next)
player.addMediaItem(insertLocation, mediaItem)
}
}
val itemsCount: Int = player.mediaItemCount
if (itemsCount > 0) {
val lastIndex = itemsCount - 1
val last: MediaItem = player.getMediaItemAt(lastIndex)
if (last.localConfiguration?.uri == VoiceNoteMediaItemFactory.NEXT_URI) {
player.removeMediaItem(lastIndex)
if (player.mediaItemCount > 1) {
val end = VoiceNoteMediaItemFactory.buildEndVoiceNoteMediaItem(last)
player.addMediaItem(lastIndex, end)
}
}
}
}
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
}
private fun indexAfter(target: MediaItem): Int {
val size: Int = player.mediaItemCount
val targetMessageId = target.mediaMetadata.extras?.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID) ?: 0L
for (i in 0 until size) {
val mediaMetadata: MediaMetadata = player.getMediaItemAt(i).mediaMetadata
val messageId = mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
if (messageId > targetMessageId) {
return i
}
}
return size
}
fun loadMoreVoiceNotes() {
if (!canLoadMore) {
return
}
val currentMediaItem: MediaItem = player.currentMediaItem ?: return
val messageId = currentMediaItem.mediaMetadata.extras!!.getLong(VoiceNoteMediaItemFactory.EXTRA_MESSAGE_ID)
SimpleTask.run(
EXECUTOR,
{ loadMediaItemsForConsecutivePlayback(messageId) }
) { mediaItems: List<MediaItem> ->
if (Util.hasItems(mediaItems) && canLoadMore) {
applyDescriptionsToQueue(mediaItems)
}
}
}
private fun loadMediaItemsForSinglePlayback(messageId: Long): List<MediaItem> {
return try {
val messageRecord = messages
.getMessageRecord(messageId)
if (!messageRecord.hasAudio()) {
Log.w(TAG, "Message does not contain audio.")
return emptyList<MediaItem>()
}
val mediaItem = VoiceNoteMediaItemFactory.buildMediaItem(context, messageRecord)
mediaItem?.let { listOf(it) } ?: emptyList()
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Could not find message.", e)
emptyList()
}
}
private fun loadMediaItemsForDraftPlayback(threadId: Long, draftUri: Uri): List<MediaItem> {
return listOf<MediaItem>(VoiceNoteMediaItemFactory.buildMediaItem(context, threadId, draftUri))
}
@WorkerThread
private fun loadMediaItemsForConsecutivePlayback(messageId: Long): List<MediaItem> {
return try {
val recordsAfter = messages.getMessagesAfterVoiceNoteInclusive(messageId, LIMIT)
recordsAfter.filter { it.hasAudio() }.stream()
.map<MediaItem?> { record: MessageRecord? ->
VoiceNoteMediaItemFactory
.buildMediaItem(context, record!!)
}
.filter { obj: MediaItem? -> Objects.nonNull(obj) }
.collect(Collectors.toList())
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Could not find message.", e)
emptyList()
}
}
}

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

@@ -32,8 +32,10 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
@@ -45,7 +47,10 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.toLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -66,6 +71,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -114,9 +121,13 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
@Composable
override fun SheetContent() {
val callLinkDetailsState by callLinkDetailsViewModel.state
val callParticipantsState by webRtcCallViewModel.callParticipantsState.observeAsState()
val participants = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
listOf(Recipient.self()) + (callParticipantsState?.allRemoteParticipants?.map { it.recipient } ?: emptyList())
val callParticipantsState by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState()
val participants: ImmutableList<CallParticipant> = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) {
listOf(CallParticipant(recipient = Recipient.self())) + (callParticipantsState?.allRemoteParticipants?.map { it } ?: emptyList())
} else {
emptyList()
}.toImmutableList()
@@ -137,11 +148,24 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
onShareLinkClicked = this::shareLink,
onEditNameClicked = onEditNameClicked,
onToggleAdminApprovalClicked = this::onApproveAllMembersChanged,
onBlock = {} // TODO [alex] -- Blocking
onBlock = this::onBlockParticipant
)
}
}
private fun onBlockParticipant(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(requireContext())
.setNegativeButton(android.R.string.cancel, null)
.setMessage(getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(requireContext())))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
ApplicationDependencies.getSignalCallManager().removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
ApplicationDependencies.getSignalCallManager().blockFromCallLink(callParticipant)
}
.show()
}
private fun onApproveAllMembersChanged(checked: Boolean) {
callLinkDetailsViewModel.setApproveAllMembers(checked)
.observeOn(AndroidSchedulers.mainThread())
@@ -210,7 +234,7 @@ private fun SheetPreview() {
),
state = SignalCallLinkState()
),
participants = listOf(Recipient.UNKNOWN).toImmutableList(),
participants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)).toImmutableList(),
onShareLinkClicked = {},
onEditNameClicked = {},
onToggleAdminApprovalClicked = {},
@@ -223,17 +247,24 @@ private fun SheetPreview() {
@Composable
private fun Sheet(
callLink: CallLinkTable.CallLink,
participants: ImmutableList<Recipient>,
participants: ImmutableList<CallParticipant>,
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onToggleAdminApprovalClicked: (Boolean) -> Unit,
onBlock: (Recipient) -> Unit
onBlock: (CallParticipant) -> Unit
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
BottomSheets.Handle()
Text(
text = stringResource(id = R.string.CallLinkInfoSheet__call_info),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(vertical = 24.dp)
)
SignalCallRow(callLink = callLink, onJoinClicked = null)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
@@ -251,9 +282,9 @@ private fun Sheet(
)
}
items(participants, { it.id }, { null }) {
items(participants, { it.callParticipantId }, { null }) {
CallLinkMemberRow(
recipient = it,
callParticipant = it,
isSelfAdmin = callLink.credentials?.adminPassBytes != null,
onBlockClicked = onBlock
)
@@ -282,7 +313,7 @@ private fun CallLinkMemberRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallLinkMemberRow(
Recipient.UNKNOWN,
CallParticipant(recipient = Recipient.UNKNOWN),
isSelfAdmin = true,
{}
)
@@ -292,37 +323,45 @@ private fun CallLinkMemberRowPreview() {
@Composable
private fun CallLinkMemberRow(
recipient: Recipient,
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (Recipient) -> Unit
onBlockClicked: (CallParticipant) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(recipient)
if (LocalInspectionMode.current) {
Spacer(
modifier = Modifier
.size(40.dp)
.background(color = Color.Red, shape = CircleShape)
)
} else {
AndroidView(
factory = ::AvatarImageView,
modifier = Modifier.size(40.dp)
) {
it.setAvatarUsingProfile(callParticipant.recipient)
}
}
Spacer(modifier = Modifier.width(24.dp))
Text(
text = recipient.getShortDisplayName(LocalContext.current),
text = callParticipant.recipient.getShortDisplayName(LocalContext.current),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
if (isSelfAdmin) {
if (isSelfAdmin && !callParticipant.recipient.isSelf) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
contentDescription = null,
modifier = Modifier
.clickable(onClick = { onBlockClicked(recipient) })
.clickable(onClick = { onBlockClicked(callParticipant) })
.align(Alignment.CenterVertically)
)
}

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