Compare commits

...

221 Commits

Author SHA1 Message Date
Nicholas Tinsley
46638a1948 Bump version to 7.7.2 2024-05-15 15:54:11 -04:00
Nicholas Tinsley
5cee85fcdc Update translations and other static files. 2024-05-15 15:44:52 -04:00
mtang-signal
f97d7e3dfd Fix permissions ask in gallery. 2024-05-15 12:18:50 -07:00
Alex Hart
6da0ecf827 Bump version to 7.7.1 2024-05-10 22:54:45 -03:00
Alex Hart
9803550bba Update baseline profile. 2024-05-10 22:51:55 -03:00
Alex Hart
15284da4c5 Update translations and other static files. 2024-05-10 22:48:55 -03:00
Alex Hart
351c3219e4 Replace RxStore with MutableStateFlow for better lifecycle control. 2024-05-10 22:44:05 -03:00
Alex Hart
ab95dbbc77 Bump version to 7.7.0 2024-05-08 16:43:30 -03:00
Alex Hart
cc6cba45c6 Update baseline profile. 2024-05-08 16:36:44 -03:00
Alex Hart
ce37660df2 Update translations and other static files. 2024-05-08 16:34:17 -03:00
Nicholas Tinsley
ca14ed9b2c Allow for captcha solving for reg v2. 2024-05-08 16:30:53 -03:00
Clark
ba4cdea75d Add cellular backup toggle for message backup. 2024-05-08 16:30:53 -03:00
Clark
83c34dd4cc Integrate swapping backup tiers from backup settings. 2024-05-08 16:30:53 -03:00
Nicholas Tinsley
b6db3802d3 Set raised hand list to be distinct by RecipientID. 2024-05-08 16:30:53 -03:00
Greyson Parrelli
a9a19d3ae0 Add job to upload thumbnails to archive. 2024-05-08 16:30:53 -03:00
Alex Hart
52fb873b1b Specify vibrate attributes to resolve vibrate from background. 2024-05-08 16:30:53 -03:00
moiseev-signal
9a0bb243cd Implement a libsignal-net shadowing web socket. 2024-05-08 16:30:53 -03:00
Nicholas Tinsley
78bbab37fb Show missing FCM dialog in registration V2. 2024-05-08 16:30:53 -03:00
Nicholas Tinsley
9af73b1409 Allow initialization of registration V2 without FCM. 2024-05-08 16:30:53 -03:00
Nicholas Tinsley
9c5bb4aa17 Initial error handling for registration v2. 2024-05-08 16:30:53 -03:00
Clark
49ba83dda8 Integrate message backup frequency. 2024-05-08 16:30:53 -03:00
Clark
de3b0d4ca2 Integrate the backup size into backup settings. 2024-05-08 16:30:53 -03:00
fm-sys
b2efc42357 Add back ability to long press title bar to go to system contact.
Resolves #13372
2024-05-08 16:30:53 -03:00
Cody Henthorne
a71faf674d Cleanup group management code. 2024-05-08 16:30:53 -03:00
moiseev-signal
34faa9003f Upgrade to libsignal 0.46.0. 2024-05-08 16:30:53 -03:00
Clark
bc527a2bc1 Basic settings functionality for message backup. 2024-05-08 16:30:53 -03:00
Nicholas Tinsley
0a3f96935a Support device transfers in restore flow v2. 2024-05-08 16:30:53 -03:00
Alex Konradi
35232a3928 Unwrap ExecutionException from Future observable 2024-05-08 16:30:53 -03:00
Alex Hart
70d74e0bb1 Allow users who have disabled Contacts permission to hide system contacts. 2024-05-08 16:30:53 -03:00
Alex Hart
36c91a95e2 Check spannable intersect when moving between stories. 2024-05-08 16:30:53 -03:00
Greyson Parrelli
4600e38a2a Add partial index to improve unread count perf. 2024-05-08 16:30:52 -03:00
Alex Hart
55abd88a03 Implement better handling for call peeking when opening the calls tab. 2024-05-08 16:30:52 -03:00
mtang-signal
cd880b0879 Expand double tap area. 2024-05-08 16:30:52 -03:00
mtang-signal
bbae6d876f Avoid translating support email strings. 2024-05-08 16:30:52 -03:00
dalamsya50
48a0c5a5a9 Fix git error when running on GitHub Actions.
Fixes #13495
Resolves #13547
2024-05-08 16:30:52 -03:00
BenjaminMuslic
c261df41b0 Added automatic capitalization to profile name fields.
Resolves #13544
2024-05-08 16:30:52 -03:00
Greyson Parrelli
cc98eced27 Short-circuit query if list is empty. 2024-05-08 16:30:52 -03:00
moiseev-signal
452d5960e4 Add test and extra cleanup around usage of incremental mac. 2024-05-08 16:30:52 -03:00
mtang-signal
c95b180728 Update gallery permission UI 2024-05-08 16:30:52 -03:00
Greyson Parrelli
3c380d35fd Attempt to reduce impact of thread updates. 2024-05-08 16:30:52 -03:00
Nicholas Tinsley
41935120e5 DeviceTransferFragment Kotlin rewrite. 2024-05-01 16:45:36 -04:00
Alex Hart
03d8f72c41 Fix group collisions error. 2024-05-01 16:45:36 -04:00
Greyson Parrelli
ab9ecff4d4 Improve timing of query methods. 2024-05-01 16:45:36 -04:00
Alex Hart
e351a0b235 Correct flags for story replies. 2024-05-01 16:45:36 -04:00
Nicholas Tinsley
4a08de370a Fix issue with Mp4Writer with massive time scales. 2024-05-01 16:45:36 -04:00
Cody Henthorne
6d657b449c Convert and update Manage Storage Settings. 2024-05-01 16:45:36 -04:00
Clark
adef572abb Store group snapshot attributes in GroupAttributeBlobs. 2024-05-01 16:45:36 -04:00
Greyson Parrelli
d6f2039bd1 Update attachment cipher tests to use longer inputs. 2024-05-01 16:45:35 -04:00
Clark
1223c3c768 Add support for new backup calls proto and call links. 2024-05-01 16:45:35 -04:00
Greyson Parrelli
333fa22c96 Bump version to 7.6.2 2024-05-01 16:42:29 -04:00
Greyson Parrelli
76c04d8d6d Update translations and other static files. 2024-05-01 16:42:03 -04:00
Greyson Parrelli
c3070f2913 Revert "Expand double tap touch area."
This reverts commit 8c81e47737.
2024-05-01 16:33:33 -04:00
Nicholas Tinsley
234b3967ed Fix button crash in v1 PIN restore fragment. 2024-05-01 10:42:23 -04:00
Greyson Parrelli
89d420cda8 Bump version to 7.6.1 2024-04-30 16:42:44 -04:00
Greyson Parrelli
ced4ece5b8 Update translations and other static files. 2024-04-30 16:41:02 -04:00
mtang-signal
8c81e47737 Expand double tap touch area. 2024-04-30 16:29:33 -04:00
Cody Henthorne
5d15eef61d Improve translations with pluralized string resources. 2024-04-30 16:04:14 -04:00
Greyson Parrelli
8f3e62245f Fix some issues where views were accessed after being destroyed. 2024-04-30 15:22:57 -04:00
Greyson Parrelli
e4ab795c62 Fix stream reading error. 2024-04-30 15:22:57 -04:00
mtang-signal
e4d6f9240f Fix double tap layout warning. 2024-04-30 14:44:00 -04:00
Greyson Parrelli
cfaf40e605 Fix KeyValueDataSet tests. 2024-04-30 11:05:39 -07:00
Greyson Parrelli
bdcf2431e7 Bump version to 7.6.0 2024-04-29 22:04:56 -04:00
Greyson Parrelli
7241283be2 Update baseline profile. 2024-04-29 22:04:56 -04:00
Greyson Parrelli
dde2a8b63a Update translations and other static files. 2024-04-29 22:04:56 -04:00
Greyson Parrelli
f7763a5b82 Be more lenient around long-int conversion in SignalStore. 2024-04-29 22:04:47 -04:00
Greyson Parrelli
c6f4a01001 Hopeful fix for crash in SimpleProgressDialog. 2024-04-29 22:04:31 -04:00
Greyson Parrelli
95a6835988 Improve handling of backup initialization. 2024-04-29 19:26:06 -04:00
moiseev-signal
f9a8f447d2 Support proxy in connections managed by libsignal. 2024-04-29 19:26:06 -04:00
Nicholas Tinsley
d20f588802 Inline the group call reactions feature flag. 2024-04-29 19:25:59 -04:00
Nicholas Tinsley
f23476a4e9 Initial support for restoring backups and skipping SMS in registration v2. 2024-04-29 19:25:59 -04:00
mtang-signal
fd4864b3b1 Update microphone permission UI for calls. 2024-04-29 19:25:59 -04:00
mtang-signal
c5c0c432c4 Update microphone permission UI for voice messages. 2024-04-29 19:25:59 -04:00
Jim Gustafson
69c40a6835 Update to RingRTC v2.41.0 2024-04-29 19:25:59 -04:00
moiseev-signal
7ef7aa65e6 Upgrade to libsignal 0.45.1. 2024-04-29 19:25:59 -04:00
Greyson Parrelli
97c08f0d52 Add additional validations to incremental attachment streams. 2024-04-29 19:25:59 -04:00
mtang-signal
18e6c57e75 Update location permission UI. 2024-04-29 19:25:59 -04:00
mtang-signal
ffc1463cda Add double tap editing feature. 2024-04-29 19:25:59 -04:00
Clark
84e654efb2 Set archive transfer state when archive data is set. 2024-04-29 19:25:59 -04:00
Clark
d983265e08 Persist group state in backup. 2024-04-29 19:25:59 -04:00
Alex Hart
e60b32202e Improved missed call state handling. 2024-04-29 19:25:59 -04:00
moiseev-signal
95fbd7a31c Implement unauthenticated chat web socket connection via libsignal-net. 2024-04-29 19:25:59 -04:00
Nicholas Tinsley
00a91e32fc Multiple skin tones for reaction bursts. 2024-04-29 19:25:59 -04:00
Alex Hart
fa32b7a883 Fix coloring on outgoing calls. 2024-04-24 15:10:12 -03:00
Alex Hart
63e6f955ed Prevent getCallLinks from returning links without root keys. 2024-04-24 14:17:58 -03:00
Alex Hart
7dcb8a425a Handle joined sync message for call links. 2024-04-24 13:31:35 -03:00
Cody Henthorne
f35ce068f9 Change profile fetch REST fallback based on authentication error. 2024-04-24 11:41:31 -04:00
Nicholas Tinsley
881d231a93 Improve group call reactions UI when presented without raise hand.
This also dismisses the custom reaction picker when switching to PiP mode.
2024-04-24 10:13:56 -04:00
Alex Hart
293634c758 Send call link update sync message upon call link creation. 2024-04-24 10:48:00 -03:00
Greyson Parrelli
4134df3f35 Use archive-specific endpoint for attachment backfill. 2024-04-23 16:29:03 -04:00
Clark
f78a019c70 Use seconds instead of millis for redemption time. 2024-04-23 15:56:38 -04:00
Cody Henthorne
d561a1385c Fix extremely long emoji search crash. 2024-04-23 12:29:03 -04:00
moiseev-signal
9b5387e221 Upgrade to libsignal 0.45.0 2024-04-23 12:29:03 -04:00
Cody Henthorne
25b1a814fe Remove legacy keyword search flag from emoji search infra. 2024-04-23 12:29:03 -04:00
Clark
b043b6e458 Schedule message backups when enabled. 2024-04-23 12:29:03 -04:00
Clark
8a972d93e9 Actually use backup jitter in local backups. 2024-04-23 12:29:03 -04:00
Cody Henthorne
8fe66a14c5 Fix multi-window camera crash. 2024-04-23 12:29:03 -04:00
Clark
f82bd64c10 Copy inbound attachments to archive service. 2024-04-23 12:29:03 -04:00
Nicholas Tinsley
4bcab49539 Correct UnopinionatedResponseCodeHandler constant name. 2024-04-23 12:29:03 -04:00
mtang-signal
0f4618ab11 Remove link preview images from shared media. 2024-04-23 12:29:03 -04:00
Cody Henthorne
475ca50fab Fix missing local participant state changes in group calls bug. 2024-04-23 12:29:03 -04:00
Greyson Parrelli
a64a02fa0c Fix issue where structured contact name syncing was delayed. 2024-04-23 12:29:02 -04:00
Clark
f3669a5865 Fix message extra column not being restored properly. 2024-04-23 12:29:02 -04:00
Greyson Parrelli
34dbd11db0 Update file format for backupV2. 2024-04-23 12:29:02 -04:00
Nicholas Tinsley
2e7279c72f Only display "Processing" text on outgoing media. 2024-04-23 12:29:02 -04:00
Nicholas Tinsley
6ad72f00af Fix phone number formatter in Registration V2. 2024-04-23 12:29:02 -04:00
Alex Hart
b771a21518 Add screen for managing backup type. 2024-04-23 12:29:02 -04:00
Greyson Parrelli
04fb459acd Remove unused backup outputstream class. 2024-04-23 12:29:02 -04:00
Greyson Parrelli
690a68f0d0 Remove libweb submodule entirely. 2024-04-23 12:29:02 -04:00
Greyson Parrelli
f34ae8d118 Add padding to the gzipped backup output. 2024-04-23 12:29:02 -04:00
Cody Henthorne
da43ff1e95 Bump version to 7.5.2 2024-04-23 11:42:24 -04:00
Cody Henthorne
f053ebbd51 Update baseline profile. 2024-04-23 11:36:15 -04:00
Cody Henthorne
87606af29c Update translations and other static files. 2024-04-23 11:30:56 -04:00
Cody Henthorne
c811bdcffa Fix benchmark test messages. 2024-04-23 11:26:36 -04:00
Cody Henthorne
0536628da3 Stagger app wake ups due to analyze database alarm. 2024-04-23 10:44:09 -04:00
Nicholas Tinsley
1fa53cfcb8 Prevent crash on attachment delete while voice note system tone is playing. 2024-04-23 10:22:01 -04:00
Cody Henthorne
a9ea3854d2 Bump version to 7.5.1 2024-04-22 17:06:18 -04:00
Cody Henthorne
dc35261e00 Update translations and other static files. 2024-04-22 16:56:39 -04:00
Cody Henthorne
716bc1f5e7 Cleanup dangling domain reference. 2024-04-22 16:52:02 -04:00
Cody Henthorne
db27204084 Validate pni signature message. 2024-04-22 16:33:03 -04:00
Cody Henthorne
42aeceffe2 Revert full usage of ActiveCallManager. 2024-04-22 16:32:27 -04:00
Greyson Parrelli
03845eabaf Bump version to 7.5.0 2024-04-18 16:44:32 -04:00
Greyson Parrelli
62af9dad50 Update translations and other static files. 2024-04-18 16:43:51 -04:00
Cody Henthorne
ee58d47926 Cycle rx message sending flag. 2024-04-18 16:24:13 -04:00
Greyson Parrelli
d74260b536 Improve network reliability. 2024-04-18 16:24:13 -04:00
Alex Hart
15d8a698c5 Add new name collision state management. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
62cf3feeaa Restore a Local Backup v2 2024-04-18 16:24:13 -04:00
Alex Hart
947ab7d48b Implement skeleton for backup sheets. 2024-04-18 16:24:13 -04:00
Greyson Parrelli
a82b9ee25f Add a job to backfill attachment uploads to the archive service. 2024-04-18 16:24:13 -04:00
mtang-signal
1e4d96b7c4 Add camera permission check to group stories. 2024-04-18 16:24:13 -04:00
Alex Hart
735a8e680c Add backupSubscription field to configuration object. 2024-04-18 16:24:13 -04:00
Alex Hart
d9e9fe1d6a Move backups selection code to its own package. 2024-04-18 16:24:13 -04:00
Greyson Parrelli
4bcd1df4f8 Expand account consistency checks. 2024-04-18 16:24:13 -04:00
Greyson Parrelli
9762899272 Remove old thread remappings. 2024-04-18 16:24:13 -04:00
Alex Hart
ce1b73970c Implement BackupStatus widget. 2024-04-18 16:24:13 -04:00
Alex Hart
58282e589b Implement backups settings fragment. 2024-04-18 16:24:13 -04:00
mtang-signal
75bd113545 Fix missing send button for voice notes. 2024-04-18 16:24:13 -04:00
Cody Henthorne
7a6bd0e1f2 Revert "Remove vestigial call camera toggle button."
This reverts commit 7a9c01e6e5.
2024-04-18 16:24:13 -04:00
Greyson Parrelli
f673c4eb83 Remove sql language annotation (for now).
It's broken in newer versions of Android Studio. It doesn't seem to
allow partial-sql anymore, only fully-formed statements. Same with
roomsql.
2024-04-18 16:24:13 -04:00
Jim Gustafson
cbb04e8f0c Update to RingRTC v2.40.0 2024-04-18 16:24:13 -04:00
mtang-signal
cd03da54d5 Fix note to self message detail text. 2024-04-18 16:24:13 -04:00
Clark
5f31f5966c Update backup locator proto. 2024-04-18 16:24:13 -04:00
Clark
d8bbfe2678 Add archived media sync job. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
7a2d408ca2 Stop voice memo playback if the current item is deleted.
Fixes #13502.
2024-04-18 16:24:13 -04:00
Nicholas Tinsley
5e4dfcc65f Add translator notes for some strings. 2024-04-18 16:24:13 -04:00
Clark
7811e51b41 Add CDN number as parameter for read credential call. 2024-04-18 16:24:13 -04:00
Alex Konradi
9703a868e5 Request new ZKC-based auth credential. 2024-04-18 16:24:13 -04:00
Alex Hart
1b7784b01f Update call strings to align with new designs. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
a83abaca1d Order story viewer names alphabetically. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
29b3f09d8a Catch possible ISE at end of re-registration. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
d36b2a23f5 Hide irrelevant rows in self about sheet. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
8f1722c718 Update placeholder label for view once media. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
5416c3b8aa Improve play button display logic on video editor fragment. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
89eeae36c4 Fix signed int overflow in disappearing timer UI message. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
eec2685e67 Registration refactor initial scaffolding. 2024-04-18 16:24:13 -04:00
Clark
318b59a6b2 Do not fallback to REST for resumable upload spec on ratelimit. 2024-04-18 16:24:13 -04:00
Nicholas Tinsley
a2e0468cd9 Remove "lower hand" confirmation dialog. 2024-04-18 16:24:13 -04:00
Clark
689eacd618 Add initial support for backup and restore of message and media to staging.
Co-authored-by: Cody Henthorne <cody@signal.org>
2024-04-18 16:24:13 -04:00
tedgravlin
8617a074ad Update CLA link in PR template. 2024-04-18 16:24:12 -04:00
Greyson Parrelli
046b8da880 Add missing static IPs.
Fixes #13513
2024-04-18 16:24:12 -04:00
Clark Chen
34a36ddfea Bump version to 7.4.2 2024-04-15 16:32:09 -04:00
Clark Chen
9330448198 Update translations and other static files. 2024-04-15 16:24:09 -04:00
Clark Chen
b3336b4d84 Revert "Use existing libsignal proguard rules."
This reverts commit 2ce6ea9a2a.
2024-04-15 10:17:36 -04:00
Alex Hart
9553c94097 Bump version to 7.4.1 2024-04-12 16:38:43 -03:00
Alex Hart
c1845ae1c4 Update baseline profile. 2024-04-12 16:33:11 -03:00
Alex Hart
b6cc3852b0 Update translations and other static files. 2024-04-12 16:28:07 -03:00
Cody Henthorne
eefc86f27e Fix dangling call notification and remove active call manager flag. 2024-04-12 09:38:06 -04:00
Nicholas Tinsley
09404157aa Add processor information to debug log. 2024-04-11 16:09:33 -04:00
Alex Hart
abfd9f8f41 Add proper capitalization settings in nickname activity. 2024-04-11 10:35:47 -03:00
Bishal
e04381fd75 Add fix for missing play button when the audio is not sent in offline mode. 2024-04-11 10:31:12 -03:00
Alex Hart
30cc3ff9fc Bump version to 7.4.0 2024-04-10 16:31:53 -03:00
Alex Hart
6f5f299035 Update baseline profile. 2024-04-10 16:31:47 -03:00
Alex Hart
02eed02cb8 Update translations and other static files. 2024-04-10 16:29:24 -03:00
Greyson Parrelli
c1d29b5c39 Set internalUser=true for nightly builds. 2024-04-10 14:54:35 -04:00
Greyson Parrelli
db4442939d Remove Environment.IS_PNP 2024-04-10 14:52:59 -04:00
tedgravlin
6ece776382 Fix navbar color in multiple instances. 2024-04-10 14:29:58 -03:00
Alex Hart
0eda714755 Send recipients when sending group story sync. 2024-04-10 14:21:34 -03:00
Greyson Parrelli
831d099503 Inline the nicknames feature flag. 2024-04-10 13:18:01 -04:00
Alex Hart
fa23e4ca70 Convert members collection to set to avoid duplicate entries. 2024-04-10 13:45:46 -03:00
Greyson Parrelli
982f602178 Regularly analyze database tables to improve index usage. 2024-04-09 16:55:25 -04:00
Greyson Parrelli
713298109a Specify indexes for mention table queries. 2024-04-09 16:18:21 -04:00
Greyson Parrelli
8793981804 Add a log section for the database schema. 2024-04-09 16:18:21 -04:00
Greyson Parrelli
9bd4e9524c Convert MentionTable to kotlin. 2024-04-09 16:18:21 -04:00
Cody Henthorne
791dc2724f Attempt to fix bad notification for call service shutdown. 2024-04-09 16:18:21 -04:00
Cody Henthorne
ba3473c61a Fix scroll to message when bubble is under toolbar. 2024-04-09 16:18:21 -04:00
moiseev-signal
3ea194255d Add getUsername default method to CredentialsProvider 2024-04-09 16:18:21 -04:00
Cody Henthorne
ea081e981f Treat unregistered user during send as general failure. 2024-04-09 16:18:21 -04:00
Alex Konradi
2ce6ea9a2a Use existing libsignal proguard rules. 2024-04-09 16:18:20 -04:00
Alex Konradi
295c9310e9 Map libsignal CDSI errors to existing exceptions. 2024-04-09 16:18:20 -04:00
Greyson Parrelli
7447ed2eac Add the ability to jump to a specific date in search. 2024-04-09 16:18:20 -04:00
Cody Henthorne
d5bf16b91a Fix incorrect thread body adjustments containing media, mentions, and styling. 2024-04-09 16:18:06 -04:00
Cody Henthorne
76665c1f0d Prevent excessive video toggling in group calls due to server instability. 2024-04-09 16:18:06 -04:00
Cody Henthorne
dd28523b05 Transition full screen call UX to terminal state when call handled by linked device. 2024-04-09 16:18:06 -04:00
Cody Henthorne
16588c401e Reduce verbosity of WebRtcViewModel event logging during calls. 2024-04-09 16:18:06 -04:00
Greyson Parrelli
dbf8a7ca87 Rotate libsignal-net flag. 2024-04-09 16:18:06 -04:00
moiseev-signal
e92c76434e Upgrade to libsignal-client 0.44.0 2024-04-09 16:18:06 -04:00
Greyson Parrelli
7adb581271 Bump version to 7.3.1 2024-04-09 16:17:21 -04:00
Greyson Parrelli
869476a41b Update translations and other static files. 2024-04-09 16:16:47 -04:00
Greyson Parrelli
8daf1bca20 Improve handling of unknown groups. 2024-04-09 15:56:15 -04:00
Greyson Parrelli
d044b3c931 Remove most lazy properties from Recipient. 2024-04-09 15:02:36 -04:00
Cody Henthorne
0fcb19e1cc Fix group recipient resolve race that can cause unknown group recipients in live cache. 2024-04-09 14:59:47 -04:00
Nicholas Tinsley
2a6977da75 Nickname screen copy update. 2024-04-04 09:45:26 -04:00
Nicholas Tinsley
26bd435bf6 Update nickname delete dialog copy. 2024-04-03 16:48:26 -04:00
Greyson Parrelli
91f8d6075c Bump version to 7.3.0 2024-04-03 15:55:34 -04:00
Greyson Parrelli
9ed9a330f4 Update baseline profile. 2024-04-03 15:54:13 -04:00
Greyson Parrelli
8bbf6b790f Update translations and other static files. 2024-04-03 15:47:24 -04:00
Greyson Parrelli
a277e9b307 Fix compilation of benchmark build. 2024-04-03 15:47:24 -04:00
Cody Henthorne
f8e6bcf290 Add username_edit release note cta action. 2024-04-03 14:07:56 -04:00
Greyson Parrelli
3ba2b46bb0 Convert Recipient to kotlin. 2024-04-03 14:02:55 -04:00
Greyson Parrelli
b50eab230d Update strings for 'system contact' -> 'phone contact'. 2024-04-03 14:02:13 -04:00
Alex Hart
3f91824325 Fix bug preventing the review sheet from opening. 2024-04-03 14:02:13 -04:00
Alex Hart
879e05148b Fix database revocation for call links. 2024-04-03 14:02:13 -04:00
moiseev-signal
78e36b85d4 Make sure not more than one libsignal Network instance is ever created
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2024-04-03 14:02:13 -04:00
Alex Hart
544cc06f13 Add chevron to conversation heading. 2024-04-03 14:02:13 -04:00
Cody Henthorne
133b7ef3f1 Fix multiple exception crash in rx message send flow. 2024-04-03 14:02:13 -04:00
Cody Henthorne
08a407dc23 Prevent thread starvation during message sending. 2024-04-03 14:02:13 -04:00
Greyson Parrelli
6c697fad8b Stop reading the PNP capability. 2024-04-03 14:02:13 -04:00
Greyson Parrelli
c904a7aa97 Delete LegacyAttachmentUploadJob.
It's been over 4 months since it was replaced. That's beyond the 90 day
build expiration + 1 day job lifespan. Should be safe to remove.
2024-04-03 14:02:13 -04:00
Greyson Parrelli
ad131d7c65 Enqueue AccountConsistency check when prekey syncs fail. 2024-04-03 14:02:13 -04:00
Alex Hart
e12d2d1e98 Fix local pip movement when in RTL language. 2024-04-03 14:02:12 -04:00
adel-signal
f01e044662 Update to new calling turn info endpoint, add support for turn server ips.
Co-authored-by: Adel Lahlou <adel@signal.com>
2024-04-03 14:02:12 -04:00
Jim Gustafson
03d3ae7043 Update to RingRTC v2.39.3 2024-04-03 14:02:12 -04:00
596 changed files with 36779 additions and 12988 deletions

View File

@@ -2,7 +2,7 @@
### First time contributor checklist
<!-- replace the empty checkboxes [ ] below with checked ones [x] accordingly -->
- [ ] I have read [how to contribute](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md) to this project
- [ ] I have signed the [Contributor License Agreement](https://whispersystems.org/cla/)
- [ ] I have signed the [Contributor License Agreement](https://signal.org/cla/)
### Contributor checklist
<!-- replace the empty checkboxes [ ] below with checked ones [x] accordingly -->

3
.gitmodules vendored
View File

@@ -1,3 +0,0 @@
[submodule "libwebp"]
path = libwebp
url = https://github.com/webmproject/libwebp.git

View File

@@ -21,8 +21,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1405
val canonicalVersionName = "7.2.4"
val canonicalVersionCode = 1419
val canonicalVersionName = "7.7.2"
val postFixSize = 100
val abiPostFix: Map<String, Int> = mapOf(
@@ -178,7 +178,6 @@ android {
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\"")
buildConfigField("String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"")
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\"")
buildConfigField("String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"")
buildConfigField("String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\"")
@@ -377,7 +376,6 @@ android {
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"acb1973aa0bbbd14b3b4e06f145497d948fd4a98efc500fcce363b3b743ec482\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")

View File

@@ -9,6 +9,7 @@ import android.Manifest
import android.app.UiAutomation
import android.os.Environment
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.InternalPlatformDsl.toArray
import okio.ByteString.Companion.toByteString
import org.junit.Assert
import org.junit.Before
@@ -20,9 +21,9 @@ import org.signal.libsignal.messagebackup.MessageBackup
import org.signal.libsignal.messagebackup.MessageBackupKey
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
import org.thoughtcrime.securesms.backup.v2.proto.Call
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
@@ -32,6 +33,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.Group
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
@@ -194,8 +196,7 @@ class ImportExportTest {
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = false,
storySendMode = Group.StorySendMode.ENABLED,
name = "Cool Group $i"
storySendMode = Group.StorySendMode.ENABLED
)
)
)
@@ -261,8 +262,7 @@ class ImportExportTest {
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = random.trueWithProbability(0.9f),
hideStory = random.trueWithProbability(0.1f),
storySendMode = if (random.trueWithProbability(0.9f)) Group.StorySendMode.ENABLED else Group.StorySendMode.DISABLED,
name = "Cool Group $i"
storySendMode = if (random.trueWithProbability(0.9f)) Group.StorySendMode.ENABLED else Group.StorySendMode.DISABLED
)
)
)
@@ -431,7 +431,12 @@ class ImportExportTest {
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.ENABLED,
name = "Cool test group"
snapshot = Group.GroupSnapshot(
title = Group.GroupAttributeBlob(title = "Group Cool"),
description = Group.GroupAttributeBlob(descriptionText = "Description"),
version = 10,
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = 1500000)
)
)
),
Recipient(
@@ -441,7 +446,12 @@ class ImportExportTest {
whitelisted = false,
hideStory = false,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
snapshot = Group.GroupSnapshot(
title = Group.GroupAttributeBlob(title = "Group Cool"),
description = Group.GroupAttributeBlob(descriptionText = "Description"),
version = 10,
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = 1500000)
)
)
)
)
@@ -592,8 +602,7 @@ class ImportExportTest {
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
storySendMode = Group.StorySendMode.DEFAULT
)
),
Chat(
@@ -611,69 +620,70 @@ class ImportExportTest {
}
@Test
fun calls() {
val individualCalls = ArrayList<Call>()
val groupCalls = ArrayList<Call>()
val states = arrayOf(Call.State.MISSED, Call.State.COMPLETED, Call.State.DECLINED_BY_USER, Call.State.DECLINED_BY_NOTIFICATION_PROFILE)
val types = arrayOf(Call.Type.VIDEO_CALL, Call.Type.AD_HOC_CALL, Call.Type.AUDIO_CALL)
var id = 1L
var timestamp = 12345L
fun individualCalls() {
val individualCalls = ArrayList<ChatItem>()
val states = arrayOf(IndividualCall.State.ACCEPTED, IndividualCall.State.NOT_ACCEPTED, IndividualCall.State.MISSED, IndividualCall.State.MISSED_NOTIFICATION_PROFILE)
val oldStates = arrayOf(IndividualCall.State.ACCEPTED, IndividualCall.State.MISSED)
val types = arrayOf(IndividualCall.Type.VIDEO_CALL, IndividualCall.Type.AUDIO_CALL)
val directions = arrayOf(IndividualCall.Direction.OUTGOING, IndividualCall.Direction.INCOMING)
var sentTime = 0L
var callId = 1L
val startedAci = TestRecipientUtils.nextAci().toByteString()
for (state in states) {
for (type in types) {
individualCalls.add(
Call(
callId = id++,
conversationRecipientId = 3,
type = type,
state = state,
timestamp = timestamp++,
ringerRecipientId = 3,
outgoing = true
for (direction in directions) {
// With call id
individualCalls.add(
ChatItem(
chatId = 1,
authorId = selfRecipient.id,
dateSent = sentTime++,
sms = false,
directionless = ChatItem.DirectionlessMessageDetails(),
updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
callId = callId++,
type = type,
state = state,
direction = direction
)
)
)
)
)
individualCalls.add(
Call(
callId = id++,
conversationRecipientId = 3,
type = type,
state = state,
timestamp = timestamp++,
ringerRecipientId = selfRecipient.id,
outgoing = false
)
)
}
}
}
for (state in oldStates) {
for (type in types) {
for (direction in directions) {
if (state == IndividualCall.State.MISSED && direction == IndividualCall.Direction.OUTGOING) continue
// Without call id
individualCalls.add(
ChatItem(
chatId = 1,
authorId = selfRecipient.id,
dateSent = sentTime++,
sms = false,
directionless = ChatItem.DirectionlessMessageDetails(),
updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
callId = null,
type = type,
state = state,
direction = direction
)
)
)
)
}
}
groupCalls.add(
Call(
callId = id++,
conversationRecipientId = 4,
type = Call.Type.GROUP_CALL,
state = state,
timestamp = timestamp++,
ringerRecipientId = 3,
outgoing = true
)
)
groupCalls.add(
Call(
callId = id++,
conversationRecipientId = 4,
type = Call.Type.GROUP_CALL,
state = state,
timestamp = timestamp++,
ringerRecipientId = selfRecipient.id,
outgoing = false
)
)
}
importExport(
*standardFrames,
Recipient(
id = 3,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
aci = startedAci,
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
@@ -694,12 +704,21 @@ class ImportExportTest {
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
hideStory = true,
storySendMode = Group.StorySendMode.DEFAULT,
name = "Cool test group"
storySendMode = Group.StorySendMode.DEFAULT
)
),
*individualCalls.toArray(),
*groupCalls.toArray()
Chat(
id = 1,
recipientId = 3,
archived = true,
pinnedOrder = 1,
expirationTimerMs = 1.days.inWholeMilliseconds,
muteUntilMs = System.currentTimeMillis(),
markedUnread = true,
dontNotifyForMentionsIfMuted = true,
wallpaper = null
),
*individualCalls.toArray()
)
}
@@ -936,7 +955,7 @@ class ImportExportTest {
chatId = 1,
authorId = alice.id,
dateSent = 101,
expireStartDate = null,
expireStartDate = 0,
expiresInMs = TimeUnit.DAYS.toMillis(1),
sms = false,
incoming = ChatItem.IncomingMessageDetails(
@@ -1008,17 +1027,47 @@ class ImportExportTest {
attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = "coolCdnKey",
cdnNumber = 2,
uploadTimestamp = System.currentTimeMillis()
uploadTimestamp = System.currentTimeMillis(),
key = (1..32).map { it.toByte() }.toByteArray().toByteString(),
size = 12345,
digest = (1..32).map { it.toByte() }.toByteArray().toByteString()
),
key = (1..32).map { it.toByte() }.toByteArray().toByteString(),
contentType = "image/png",
size = 12345,
fileName = "very_cool_picture.png",
width = 100,
height = 200,
caption = "Love this cool picture!",
incrementalMacChunkSize = 0
)
),
wasDownloaded = true
),
MessageAttachment(
pointer = FilePointer(
invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator(),
contentType = "image/png",
width = 100,
height = 200,
caption = "Love this cool picture! Too bad u cant download it",
incrementalMacChunkSize = 0
),
wasDownloaded = false
),
MessageAttachment(
pointer = FilePointer(
backupLocator = FilePointer.BackupLocator(
"digestherebutimlazy",
cdnNumber = 3,
key = (1..32).map { it.toByte() }.toByteArray().toByteString(),
digest = (1..64).map { it.toByte() }.toByteArray().toByteString(),
size = 12345
),
contentType = "image/png",
width = 100,
height = 200,
caption = "Love this cool picture! Too bad u cant download it",
incrementalMacChunkSize = 0
),
wasDownloaded = true
)
)
)
@@ -1338,7 +1387,7 @@ class ImportExportTest {
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is Call -> writer.write(Frame(call = obj))
is AdHocCall -> writer.write(Frame(adHocCall = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
@@ -1399,7 +1448,7 @@ class ImportExportTest {
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is Call -> writer.write(Frame(call = obj))
is AdHocCall -> writer.write(Frame(adHocCall = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
@@ -1430,8 +1479,8 @@ class ImportExportTest {
val chatsExported = ArrayList<Chat>()
val chatItemsImported = ArrayList<ChatItem>()
val chatItemsExported = ArrayList<ChatItem>()
val callsImported = ArrayList<Call>()
val callsExported = ArrayList<Call>()
val callsImported = ArrayList<AdHocCall>()
val callsExported = ArrayList<AdHocCall>()
val stickersImported = ArrayList<StickerPack>()
val stickersExported = ArrayList<StickerPack>()
@@ -1441,7 +1490,7 @@ class ImportExportTest {
f.recipient != null -> recipientsImported.add(f.recipient!!)
f.chat != null -> chatsImported.add(f.chat!!)
f.chatItem != null -> chatItemsImported.add(f.chatItem!!)
f.call != null -> callsImported.add(f.call!!)
f.adHocCall != null -> callsImported.add(f.adHocCall!!)
f.stickerPack != null -> stickersImported.add(f.stickerPack!!)
}
}
@@ -1452,7 +1501,7 @@ class ImportExportTest {
f.recipient != null -> recipientsExported.add(f.recipient!!)
f.chat != null -> chatsExported.add(f.chat!!)
f.chatItem != null -> chatItemsExported.add(f.chatItem!!)
f.call != null -> callsExported.add(f.call!!)
f.adHocCall != null -> callsExported.add(f.adHocCall!!)
f.stickerPack != null -> stickersExported.add(f.stickerPack!!)
}
}

View File

@@ -7,6 +7,7 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
import org.thoughtcrime.securesms.database.MessageType
@@ -15,7 +16,6 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
@@ -137,7 +137,7 @@ class ConversationItemPreviewer {
private fun attachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
Cdn.CDN_3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,

View File

@@ -328,5 +328,7 @@ class V2ConversationItemShapeTest {
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(item: MultiselectPart) = Unit
}
}

View File

@@ -14,6 +14,7 @@ import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.update
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -742,7 +743,7 @@ class AttachmentTableTest_deduping {
assertArrayEquals(lhsAttachment.remoteDigest, rhsAttachment.remoteDigest)
assertArrayEquals(lhsAttachment.incrementalDigest, rhsAttachment.incrementalDigest)
assertEquals(lhsAttachment.incrementalMacChunkSize, rhsAttachment.incrementalMacChunkSize)
assertEquals(lhsAttachment.cdnNumber, rhsAttachment.cdnNumber)
assertEquals(lhsAttachment.cdn.cdnNumber, rhsAttachment.cdn.cdnNumber)
}
fun assertDoesNotHaveRemoteFields(attachmentId: AttachmentId) {
@@ -751,7 +752,7 @@ class AttachmentTableTest_deduping {
assertNull(databaseAttachment.remoteLocation)
assertNull(databaseAttachment.remoteDigest)
assertNull(databaseAttachment.remoteKey)
assertEquals(0, databaseAttachment.cdnNumber)
assertEquals(0, databaseAttachment.cdn.cdnNumber)
}
fun assertSkipTransform(attachmentId: AttachmentId, state: Boolean) {
@@ -776,7 +777,7 @@ class AttachmentTableTest_deduping {
AttachmentTable.TRANSFER_PROGRESS_DONE,
databaseAttachment.size, // size
null,
3, // cdnNumber
Cdn.CDN_3, // cdnNumber
location,
key,
digest,

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.database
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
@@ -75,21 +74,6 @@ class GroupTableTest {
assertEquals(2, groups.size)
}
@Test
fun givenGroups_whenIQueryGroupsByMembership_thenIExpectBothGroups() {
insertPushGroup()
insertMmsGroup(members = listOf(harness.others[1]))
val groups = groupTable.queryGroupsByMembership(
setOf(harness.self.id, harness.others[1]),
includeInactive = false,
excludeV1 = false,
excludeMms = false
)
assertEquals(2, groups.cursor?.count)
}
@Test
fun givenGroups_whenIGetGroups_thenIExpectBothGroups() {
insertPushGroup()
@@ -181,68 +165,6 @@ class GroupTableTest {
assertFalse(actual)
}
@Test
fun givenAGroup_whenIUpdateMembers_thenIExpectUpdatedMembers() {
val v2Group = insertPushGroup()
groupTable.updateMembers(v2Group, listOf(harness.self.id, harness.others[1]))
val groupRecord = groupTable.getGroup(v2Group)
assertEquals(setOf(harness.self.id, harness.others[1]), groupRecord.get().members.toSet())
}
@Test
fun givenAnMmsGroup_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val members: List<RecipientId> = listOf(harness.self.id, harness.others[0])
val other = insertMmsGroup(members + listOf(harness.others[1]))
val mmsGroup = insertMmsGroup(members)
val actual = groupTable.getOrCreateMmsGroupForMembers(members.toSet())
assertNotEquals(other, actual)
assertEquals(mmsGroup, actual)
}
@Test
fun givenMultipleMmsGroups_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1])
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2])
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMultipleMmsGroupsWithDifferentMemberOrders_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val group1Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[1], harness.others[2]).shuffled()
val group2Members: List<RecipientId> = listOf(harness.self.id, harness.others[0], harness.others[2], harness.others[3]).shuffled()
val group1: GroupId = insertMmsGroup(group1Members)
val group2: GroupId = insertMmsGroup(group2Members)
val group1Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group1Members.shuffled().toSet())
val group2Result: GroupId = groupTable.getOrCreateMmsGroupForMembers(group2Members.shuffled().toSet())
assertEquals(group1, group1Result)
assertEquals(group2, group2Result)
assertNotEquals(group1Result, group2Result)
}
@Test
fun givenMmsGroupWithOneMember_whenIGetOrCreateMmsGroup_thenIExpectMyMmsGroup() {
val groupMembers: List<RecipientId> = listOf(harness.self.id)
val group: GroupId = insertMmsGroup(groupMembers)
val groupResult: GroupId = groupTable.getOrCreateMmsGroupForMembers(groupMembers.toSet())
assertEquals(group, groupResult)
}
@Test
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
val g1 = insertPushGroup(listOf())

View File

@@ -0,0 +1,239 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIsSize
@RunWith(AndroidJUnit4::class)
class NameCollisionTablesTest {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var alice: RecipientId
private lateinit var bob: RecipientId
private lateinit var charlie: RecipientId
@Before
fun setUp() {
alice = setUpRecipient(harness.others[0])
bob = setUpRecipient(harness.others[1])
charlie = setUpRecipient(harness.others[2])
}
@Test
fun givenAUserWithAThreadIdButNoConflicts_whenIGetCollisionsForThreadRecipient_thenIExpectNoCollisions() {
val threadRecipientId = alice
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(threadRecipientId))
val actual = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(threadRecipientId)
actual assertIsSize 0
}
@Test
fun givenTwoUsers_whenOneChangesTheirProfileNameToMatchTheOther_thenIExpectANameCollision() {
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
actualAlice assertIsSize 2
actualBob assertIsSize 2
}
@Test
fun givenTwoUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectNoNameCollisions() {
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
actualAlice assertIsSize 0
actualBob assertIsSize 0
}
@Test
fun givenThreeUsersWithANameCollisions_whenOneChangesToADifferentName_thenIExpectTwoNameCollisions() {
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
setProfileName(charlie, ProfileName.fromParts("Bob", "Android"))
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
val actualBob = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
val actualCharlie = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(charlie)
actualAlice assertIsSize 0
actualBob assertIsSize 2
actualCharlie assertIsSize 2
}
@Test
fun givenTwoUsersWithADismissedNameCollision_whenOneChangesToADifferentNameAndBack_thenIExpectANameCollision() {
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
setProfileName(alice, ProfileName.fromParts("Alice", "Android"))
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
val actualAlice = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
actualAlice assertIsSize 2
}
@Test
fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() {
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
actualCollisions assertIsSize 0
}
@Test
fun givenADismissedNameCollisionForAliceThatIUpdate_whenIGetNameCollisionsForAlice_thenIExpectNoNameCollisions() {
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(alice)
actualCollisions assertIsSize 0
}
@Test
fun givenADismissedNameCollisionForAlice_whenIGetNameCollisionsForBob_thenIExpectANameCollisionWithTwoEntries() {
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(alice))
setProfileName(alice, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(alice)
val actualCollisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(bob)
actualCollisions assertIsSize 2
}
@Test
fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() {
val alice = Recipient.resolved(alice)
val bob = Recipient.resolved(bob)
val info = createGroup()
setProfileName(alice.id, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
collisions assertIsSize 2
}
@Test
fun givenAGroupWithAliceAndBobWithDismissedCollision_whenIInsertNameChangeMessageForAlice_thenIExpectAGroupNameCollision() {
val alice = Recipient.resolved(alice)
val bob = Recipient.resolved(bob)
val info = createGroup()
setProfileName(alice.id, ProfileName.fromParts("Bob", "Android"))
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
SignalDatabase.nameCollisions.markCollisionsForThreadRecipientDismissed(info.recipientId)
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Bob Android", "Alice Android")
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
collisions assertIsSize 0
}
@Test
fun givenAGroupWithAliceAndBob_whenIInsertNameChangeMessageForAliceWithMismatch_thenIExpectNoGroupNameCollision() {
val alice = Recipient.resolved(alice)
val bob = Recipient.resolved(bob)
val info = createGroup()
setProfileName(alice.id, ProfileName.fromParts("Alice", "Android"))
setProfileName(bob.id, ProfileName.fromParts("Bob", "Android"))
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(info.recipientId))
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "Alice Android", "Bob Android")
val collisions = SignalDatabase.nameCollisions.getCollisionsForThreadRecipientId(info.recipientId)
collisions assertIsSize 0
}
private fun setUpRecipient(recipientId: RecipientId): RecipientId {
SignalDatabase.recipients.setProfileSharing(recipientId, false)
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false)
MmsHelper.insert(
threadId = threadId,
message = IncomingMessage(
type = MessageType.NORMAL,
from = recipientId,
groupId = null,
body = "hi",
sentTimeMillis = 100L,
receivedTimeMillis = 200L,
serverTimeMillis = 100L,
isUnidentified = true
)
)
return recipientId
}
private fun setProfileName(recipientId: RecipientId, name: ProfileName) {
SignalDatabase.recipients.setProfileName(recipientId, name)
SignalDatabase.nameCollisions.handleIndividualNameCollision(recipientId)
}
private fun createGroup(): GroupTestingUtils.TestGroupInfo {
return GroupTestingUtils.insertGroup(
revision = 0,
DecryptedMember(
aciBytes = harness.self.requireAci().toByteString(),
role = Member.Role.ADMINISTRATOR
),
DecryptedMember(
aciBytes = Recipient.resolved(alice).requireAci().toByteString(),
role = Member.Role.ADMINISTRATOR
),
DecryptedMember(
aciBytes = Recipient.resolved(bob).requireAci().toByteString(),
role = Member.Role.ADMINISTRATOR
)
)
}
}

View File

@@ -141,7 +141,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View File

@@ -1,5 +1,6 @@
package org.signal.benchmark.setup
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageType
@@ -9,7 +10,6 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
@@ -144,7 +144,7 @@ object TestMessages {
}
private fun imageAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
"image/webp",
null,
@@ -167,7 +167,7 @@ object TestMessages {
private fun voiceAttachment(): SignalServiceAttachmentPointer {
return SignalServiceAttachmentPointer(
ReleaseChannel.CDN_NUMBER,
Cdn.S3.cdnNumber,
SignalServiceAttachmentRemoteId.from(""),
"audio/aac",
null,

View File

@@ -100,7 +100,7 @@ object TestUsers {
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View File

@@ -67,7 +67,8 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
hasWallpaper = springboardViewModel.hasWallpaper.value,
colorizer = Colorizer(),
startExpirationTimeout = {},
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) }
chatColorsDataProvider = { ChatColorsDrawable.ChatColorsData(null, null) },
displayDialogFragment = {}
)
if (springboardViewModel.hasWallpaper.value) {
@@ -299,6 +300,10 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onItemDoubleClick(item: MultiselectPart) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onShowSafetyTips(forGroup: Boolean) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}

View File

@@ -749,7 +749,7 @@
android:exported="false"/>
<activity
android:name=".backup.v2.ui.MessageBackupsFlowActivity"
android:name=".backup.v2.ui.subscription.MessageBackupsFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
@@ -837,6 +837,20 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registration.v2.ui.RegistrationV2Activity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".restore.RestoreActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".revealable.ViewOnceMessageActivity"
android:launchMode="singleTask"
android:theme="@style/TextSecure.FullScreenMedia"
@@ -962,7 +976,7 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.MessageBackupsTestRestoreActivity"
<activity android:name=".backup.v2.ui.subscription.MessageBackupsTestRestoreActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:exported="false"/>
@@ -1168,6 +1182,10 @@
android:name=".service.AttachmentProgressService"
android:exported="false"/>
<service
android:name=".service.BackupProgressService"
android:exported="false"/>
<service
android:name=".gcm.FcmFetchBackgroundService"
android:exported="false"/>
@@ -1288,6 +1306,12 @@
</intent-filter>
</receiver>
<receiver android:name=".service.AnalyzeDatabaseAlarmListener" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name="org.thoughtcrime.securesms.jobs.ForegroundServiceUtil$Receiver" android:exported="false" />
<receiver android:name=".service.PersistentConnectionBootListener" android:exported="false">
@@ -1352,7 +1376,11 @@
</intent-filter>
</receiver>
<service android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService" android:exported="false" />
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<receiver android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallServiceReceiver" android:exported="false">
<intent-filter>
<action android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallAction.DENY"/>

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@ import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
@@ -83,12 +84,14 @@ import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationUtil;
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
import org.thoughtcrime.securesms.service.AnalyzeDatabaseAlarmListener;
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -217,6 +220,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(AccountConsistencyWorkerJob::enqueueIfNecessary)
.addPostRender(GroupRingCleanupJob::enqueue)
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
.addPostRender(() -> ActiveCallManager.clearNotifications(this))
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -417,8 +421,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
RotateSignedPreKeyListener.schedule(this);
DirectoryRefreshListener.schedule(this);
LocalBackupListener.schedule(this);
MessageBackupListener.schedule(this);
RotateSenderCertificateListener.schedule(this);
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
AnalyzeDatabaseAlarmListener.schedule(this);
if (BuildConfig.MANAGES_APP_UPDATES) {
ApkUpdateRefreshListener.schedule(this);

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms;
import android.net.Uri;
import android.view.GestureDetector;
import android.view.View;
import androidx.annotation.NonNull;
@@ -58,6 +59,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void setEventListener(@Nullable EventListener listener);
default void setGestureDetector(@Nullable GestureDetector gestureDetector) {
// Intentionally Blank.
}
default void setParentScrolling(boolean isParentScrolling) {
// Intentionally Blank.
}
@@ -126,5 +131,6 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onShowSafetyTips(boolean forGroup);
void onReportSpamLearnMoreClicked();
void onMessageRequestAcceptOptionsClicked();
void onItemDoubleClick(MultiselectPart multiselectPart);
}
}

View File

@@ -136,7 +136,7 @@ public class NewConversationActivity extends ContactSelectionActivity
if (result instanceof RecipientRepository.LookupResult.Success) {
Recipient resolved = Recipient.resolved(((RecipientRepository.LookupResult.Success) result).getRecipientId());
if (resolved.isRegistered() && resolved.hasServiceId()) {
if (resolved.isRegistered() && resolved.getHasServiceId()) {
launch(resolved);
}
} else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) {
@@ -334,13 +334,7 @@ public class NewConversationActivity extends ContactSelectionActivity
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
getString(R.string.NewConversationActivity__remove),
R.color.signal_colorOnSurface,
() -> {
if (recipient.isSystemContact()) {
displayIsInSystemContactsDialog(recipient);
} else {
displayRemovalDialog(recipient);
}
}
() -> displayRemovalDialog(recipient)
);
}

View File

@@ -19,6 +19,7 @@ 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.keyvalue.InternalValues;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
@@ -28,8 +29,11 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
import org.thoughtcrime.securesms.registration.v2.ui.RegistrationV2Activity;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
@@ -51,6 +55,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
private static final int STATE_TRANSFER_ONGOING = 8;
private static final int STATE_TRANSFER_LOCKED = 9;
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
private static final int STATE_RESTORE_BACKUP = 11;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -125,8 +130,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private void routeApplicationState(boolean locked) {
Intent intent = getIntentForState(getApplicationState(locked));
final int applicationState = getApplicationState(locked);
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
}
@@ -146,6 +153,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
case STATE_RESTORE_BACKUP: return getRestoreIntent();
default: return null;
}
}
@@ -159,6 +167,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_UI_BLOCKING_UPGRADE;
} else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) {
return STATE_WELCOME_PUSH_SCREEN;
} else if (SignalStore.internalValues().enterRestoreV2Flow()) {
return STATE_RESTORE_BACKUP;
} else if (SignalStore.storageService().needsAccountRestore()) {
return STATE_ENTER_SIGNAL_PIN;
} else if (userHasSkippedOrForgottenPin()) {
@@ -208,7 +218,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
if (FeatureFlags.registrationV2()) {
return RegistrationV2Activity.newIntentForNewRegistration(this, getIntent());
} else {
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -227,6 +241,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return getRoutedIntent(CreateSvrPinActivity.class, intent);
}
private Intent getRestoreIntent() {
Intent intent = RestoreActivity.getIntentForRestore(this);
return getRoutedIntent(intent, getIntent());
}
private Intent getCreateProfileNameIntent() {
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
return getRoutedIntent(intent, getIntent());

View File

@@ -35,6 +35,7 @@ import android.view.WindowManager;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.app.PictureInPictureModeChangedInfo;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveDataReactiveStreams;
@@ -61,6 +62,7 @@ import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber;
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
@@ -162,6 +164,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private ControlsAndInfoController controlsAndInfo;
private boolean enterPipOnResume;
private long lastProcessedIntentTimestamp;
private WebRtcViewModel previousEvent = null;
private Disposable ephemeralStateDisposable = Disposable.empty();
@@ -222,6 +225,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
processIntent(getIntent());
registerSystemPipChangeListeners();
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
windowInfoTrackerCallbackAdapter = new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
@@ -234,6 +239,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface));
}
private void registerSystemPipChangeListeners() {
addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfo -> {
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
CallReactionScrubber.dismissCustomEmojiBottomSheet(getSupportFragmentManager());
});
}
@Override
protected void onStart() {
super.onStart();
@@ -356,8 +368,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
return false;
}
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
return true;
}
if (Build.VERSION.SDK_INT >= 31) {
@@ -885,7 +895,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull WebRtcViewModel event) {
Log.i(TAG, "Got message from service: " + event);
Log.i(TAG, "Got message from service: " + event.describeDifference(previousEvent));
previousEvent = event;
viewModel.setRecipient(event.getRecipient());
callScreen.setRecipient(event.getRecipient());

View File

@@ -54,6 +54,7 @@ class SignalBackupAgent : BackupAgent() {
items.find { dataInput.key == it.getKey() }?.restoreData(buffer)
}
DataOutputStream(FileOutputStream(newState.fileDescriptor)).use { it.writeInt(cumulativeHashCode()) }
Log.i(TAG, "Android Backup Service complete.")
}
private fun cumulativeHashCode(): Int {

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.net.Uri
import android.os.Parcel
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
class ArchivedAttachment : Attachment {
@JvmField
val archiveCdn: Int
@JvmField
val archiveMediaName: String
@JvmField
val archiveMediaId: String
constructor(
contentType: String?,
size: Long,
cdn: Int,
key: ByteArray,
cdnKey: String?,
archiveCdn: Int?,
archiveMediaName: String,
archiveMediaId: String,
digest: ByteArray,
incrementalMac: ByteArray?,
incrementalMacChunkSize: Int?,
width: Int?,
height: Int?,
caption: String?,
blurHash: String?,
voiceNote: Boolean,
borderless: Boolean,
gif: Boolean,
quote: Boolean
) : super(
contentType = contentType ?: "",
quote = quote,
transferState = AttachmentTable.TRANSFER_NEEDS_RESTORE,
size = size,
fileName = null,
cdn = Cdn.fromCdnNumber(cdn),
remoteLocation = cdnKey,
remoteKey = Base64.encodeWithoutPadding(key),
remoteDigest = digest,
incrementalDigest = incrementalMac,
fastPreflightId = null,
voiceNote = voiceNote,
borderless = borderless,
videoGif = gif,
width = width ?: 0,
height = height ?: 0,
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
uploadTimestamp = 0,
caption = caption,
stickerLocator = null,
blurHash = BlurHash.parseOrNull(blurHash),
audioHash = null,
transformProperties = null
) {
this.archiveCdn = archiveCdn ?: Cdn.CDN_3.cdnNumber
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
}
constructor(parcel: Parcel) : super(parcel) {
archiveCdn = parcel.readInt()
archiveMediaName = parcel.readString()!!
archiveMediaId = parcel.readString()!!
}
override fun writeToParcel(dest: Parcel, flags: Int) {
super.writeToParcel(dest, flags)
dest.writeInt(archiveCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
}
override val uri: Uri? = null
override val publicUri: Uri? = null
}

View File

@@ -29,7 +29,7 @@ abstract class Attachment(
@JvmField
val fileName: String?,
@JvmField
val cdnNumber: Int,
val cdn: Cdn,
@JvmField
val remoteLocation: String?,
@JvmField
@@ -76,7 +76,7 @@ abstract class Attachment(
transferState = parcel.readInt(),
size = parcel.readLong(),
fileName = parcel.readString(),
cdnNumber = parcel.readInt(),
cdn = Cdn.deserialize(parcel.readInt()),
remoteLocation = parcel.readString(),
remoteKey = parcel.readString(),
remoteDigest = ParcelUtil.readByteArray(parcel),
@@ -103,7 +103,7 @@ abstract class Attachment(
dest.writeInt(transferState)
dest.writeLong(size)
dest.writeString(fileName)
dest.writeInt(cdnNumber)
dest.writeInt(cdn.serialize())
dest.writeString(remoteLocation)
dest.writeString(remoteKey)
ParcelUtil.writeByteArray(dest, remoteDigest)

View File

@@ -17,7 +17,8 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
DATABASE(DatabaseAttachment::class.java, "database"),
POINTER(PointerAttachment::class.java, "pointer"),
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
URI(UriAttachment::class.java, "uri")
URI(UriAttachment::class.java, "uri"),
ARCHIVED(ArchivedAttachment::class.java, "archived")
}
@JvmStatic
@@ -34,6 +35,7 @@ object AttachmentCreator : Parcelable.Creator<Attachment> {
Subclass.POINTER -> PointerAttachment(source)
Subclass.TOMBSTONE -> TombstoneAttachment(source)
Subclass.URI -> UriAttachment(source)
Subclass.ARCHIVED -> ArchivedAttachment(source)
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import org.signal.core.util.logging.Log
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.blurhash.BlurHashEncoder
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.util.MediaUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.IOException
import java.util.Objects
/**
* A place collect common attachment upload operations to allow for code reuse.
*/
object AttachmentUploadUtil {
private val TAG = Log.tag(AttachmentUploadUtil::class.java)
/**
* Builds a [SignalServiceAttachmentStream] from the provided data, which can then be provided to various upload methods.
*/
@Throws(IOException::class)
fun buildSignalServiceAttachmentStream(
context: Context,
attachment: Attachment,
uploadSpec: ResumableUpload,
cancellationSignal: (() -> Boolean)? = null,
progressListener: ProgressListener? = null
): SignalServiceAttachmentStream {
val inputStream = PartAuthority.getAttachmentStream(context, attachment.uri!!)
val builder = SignalServiceAttachment.newStreamBuilder()
.withStream(inputStream)
.withContentType(attachment.contentType)
.withLength(attachment.size)
.withFileName(attachment.fileName)
.withVoiceNote(attachment.voiceNote)
.withBorderless(attachment.borderless)
.withGif(attachment.videoGif)
.withFaststart(attachment.transformProperties?.mp4FastStart ?: false)
.withWidth(attachment.width)
.withHeight(attachment.height)
.withUploadTimestamp(System.currentTimeMillis())
.withCaption(attachment.caption)
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
.withCancelationSignal(cancellationSignal)
.withListener(progressListener)
if (MediaUtil.isImageType(attachment.contentType)) {
builder.withBlurHash(getImageBlurHash(context, attachment))
} else if (MediaUtil.isVideoType(attachment.contentType)) {
builder.withBlurHash(getVideoBlurHash(context, attachment))
}
return builder.build()
}
@Throws(IOException::class)
private fun getImageBlurHash(context: Context, attachment: Attachment): String? {
if (attachment.blurHash != null) {
return attachment.blurHash!!.hash
}
if (attachment.uri == null) {
return null
}
return PartAuthority.getAttachmentStream(context, attachment.uri!!).use { inputStream ->
BlurHashEncoder.encode(inputStream)
}
}
@Throws(IOException::class)
private fun getVideoBlurHash(context: Context, attachment: Attachment): String? {
if (attachment.blurHash != null) {
return attachment.blurHash.hash
}
if (Build.VERSION.SDK_INT < 23) {
Log.w(TAG, "Video thumbnails not supported...")
return null
}
return MediaUtil.getVideoThumbnail(context, Objects.requireNonNull(attachment.uri), 1000)?.let { bitmap ->
val thumb = Bitmap.createScaledBitmap(bitmap, 100, 100, false)
bitmap.recycle()
Log.i(TAG, "Generated video thumbnail...")
val hash = BlurHashEncoder.encode(thumb)
thumb.recycle()
hash
}
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.attachments
import org.signal.core.util.IntSerializer
/**
* Attachments/media can come from and go to multiple CDN locations depending on when and where
* they were uploaded. This class represents the CDNs where attachments/media can live.
*/
enum class Cdn(private val value: Int) {
S3(-1),
CDN_0(0),
CDN_2(2),
CDN_3(3);
val cdnNumber: Int
get() {
return when (this) {
S3 -> -1
CDN_0 -> 0
CDN_2 -> 2
CDN_3 -> 3
}
}
fun serialize(): Int {
return Serializer.serialize(this)
}
companion object Serializer : IntSerializer<Cdn> {
override fun serialize(data: Cdn): Int {
return data.value
}
override fun deserialize(data: Int): Cdn {
return values().first { it.value == data }
}
fun fromCdnNumber(cdnNumber: Int): Cdn {
return when (cdnNumber) {
-1 -> S3
0 -> CDN_0
2 -> CDN_2
3 -> CDN_3
else -> throw UnsupportedOperationException()
}
}
}
}

View File

@@ -25,6 +25,15 @@ class DatabaseAttachment : Attachment {
@JvmField
val dataHash: String?
@JvmField
val archiveCdn: Int
@JvmField
val archiveMediaName: String?
@JvmField
val archiveMediaId: String?
private val hasThumbnail: Boolean
val displayOrder: Int
@@ -37,7 +46,7 @@ class DatabaseAttachment : Attachment {
transferProgress: Int,
size: Long,
fileName: String?,
cdnNumber: Int,
cdn: Cdn,
location: String?,
key: String?,
digest: ByteArray?,
@@ -57,13 +66,16 @@ class DatabaseAttachment : Attachment {
transformProperties: TransformProperties?,
displayOrder: Int,
uploadTimestamp: Long,
dataHash: String?
dataHash: String?,
archiveCdn: Int,
archiveMediaName: String?,
archiveMediaId: String?
) : super(
contentType = contentType!!,
transferState = transferProgress,
size = size,
fileName = fileName,
cdnNumber = cdnNumber,
cdn = cdn,
remoteLocation = location,
remoteKey = key,
remoteDigest = digest,
@@ -88,6 +100,9 @@ class DatabaseAttachment : Attachment {
this.dataHash = dataHash
this.hasThumbnail = hasThumbnail
this.displayOrder = displayOrder
this.archiveCdn = archiveCdn
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
}
constructor(parcel: Parcel) : super(parcel) {
@@ -97,6 +112,9 @@ class DatabaseAttachment : Attachment {
hasThumbnail = ParcelUtil.readBoolean(parcel)
mmsId = parcel.readLong()
displayOrder = parcel.readInt()
archiveCdn = parcel.readInt()
archiveMediaName = parcel.readString()
archiveMediaId = parcel.readString()
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@@ -107,6 +125,9 @@ class DatabaseAttachment : Attachment {
ParcelUtil.writeBoolean(dest, hasThumbnail)
dest.writeLong(mmsId)
dest.writeInt(displayOrder)
dest.writeInt(archiveCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
}
override val uri: Uri?

View File

@@ -9,7 +9,6 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.whispersystems.signalservice.api.InvalidMessageStructureException
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil
import org.whispersystems.signalservice.internal.push.DataMessage
import java.util.Optional
@@ -21,7 +20,7 @@ class PointerAttachment : Attachment {
transferState: Int,
size: Long,
fileName: String?,
cdnNumber: Int,
cdn: Cdn,
location: String,
key: String?,
digest: ByteArray?,
@@ -42,7 +41,7 @@ class PointerAttachment : Attachment {
transferState = transferState,
size = size,
fileName = fileName,
cdnNumber = cdnNumber,
cdn = cdn,
remoteLocation = location,
remoteKey = key,
remoteDigest = digest,
@@ -83,7 +82,7 @@ class PointerAttachment : Attachment {
@JvmStatic
@JvmOverloads
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null): Optional<Attachment> {
fun forPointer(pointer: Optional<SignalServiceAttachment>, stickerLocator: StickerLocator? = null, fastPreflightId: String? = null, transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING): Optional<Attachment> {
if (!pointer.isPresent || !pointer.get().isPointer) {
return Optional.empty()
}
@@ -97,10 +96,10 @@ class PointerAttachment : Attachment {
return Optional.of(
PointerAttachment(
contentType = pointer.get().contentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
transferState = transferState,
size = pointer.get().asPointer().size.orElse(0).toLong(),
fileName = pointer.get().asPointer().fileName.orElse(null),
cdnNumber = pointer.get().asPointer().cdnNumber,
cdn = Cdn.fromCdnNumber(pointer.get().asPointer().cdnNumber),
location = pointer.get().asPointer().remoteId.toString(),
key = encodedKey,
digest = pointer.get().asPointer().digest.orElse(null),
@@ -120,35 +119,6 @@ class PointerAttachment : Attachment {
)
}
fun forPointer(pointer: SignalServiceDataMessage.Quote.QuotedAttachment): Optional<Attachment> {
val thumbnail = pointer.thumbnail
return Optional.of(
PointerAttachment(
contentType = pointer.contentType,
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = pointer.fileName,
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
digest = thumbnail?.asPointer()?.digest?.orElse(null),
incrementalDigest = thumbnail?.asPointer()?.incrementalDigest?.orElse(null),
incrementalMacChunkSize = thumbnail?.asPointer()?.incrementalMacChunkSize ?: 0,
fastPreflightId = null,
voiceNote = false,
borderless = false,
videoGif = false,
width = thumbnail?.asPointer()?.width ?: 0,
height = thumbnail?.asPointer()?.height ?: 0,
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
caption = thumbnail?.asPointer()?.caption?.orElse(null),
stickerLocator = null,
blurHash = null
)
)
}
fun forPointer(quotedAttachment: DataMessage.Quote.QuotedAttachment): Optional<Attachment> {
val thumbnail: SignalServiceAttachment? = try {
if (quotedAttachment.thumbnail != null) {
@@ -166,7 +136,7 @@ class PointerAttachment : Attachment {
transferState = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = (if (thumbnail != null) thumbnail.asPointer().size.orElse(0) else 0).toLong(),
fileName = quotedAttachment.fileName,
cdnNumber = thumbnail?.asPointer()?.cdnNumber ?: 0,
cdn = Cdn.fromCdnNumber(thumbnail?.asPointer()?.cdnNumber ?: 0),
location = thumbnail?.asPointer()?.remoteId?.toString() ?: "0",
key = if (thumbnail != null && thumbnail.asPointer().key != null) encodeWithPadding(thumbnail.asPointer().key) else null,
digest = thumbnail?.asPointer()?.digest?.orElse(null),

View File

@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.attachments
import android.net.Uri
import android.os.Parcel
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
/**
@@ -17,7 +18,7 @@ class TombstoneAttachment : Attachment {
transferState = AttachmentTable.TRANSFER_PROGRESS_DONE,
size = 0,
fileName = null,
cdnNumber = 0,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,
@@ -37,6 +38,44 @@ class TombstoneAttachment : Attachment {
transformProperties = null
)
constructor(
contentType: String?,
incrementalMac: ByteArray?,
incrementalMacChunkSize: Int?,
width: Int?,
height: Int?,
caption: String?,
blurHash: String?,
voiceNote: Boolean = false,
borderless: Boolean = false,
gif: Boolean = false,
quote: Boolean
) : super(
contentType = contentType ?: "",
quote = quote,
transferState = AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE,
size = 0,
fileName = null,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,
incrementalDigest = incrementalMac,
fastPreflightId = null,
voiceNote = voiceNote,
borderless = borderless,
videoGif = gif,
width = width ?: 0,
height = height ?: 0,
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
uploadTimestamp = 0,
caption = caption,
stickerLocator = null,
blurHash = BlurHash.parseOrNull(blurHash),
audioHash = null,
transformProperties = null
)
constructor(parcel: Parcel) : super(parcel)
override val uri: Uri? = null

View File

@@ -69,7 +69,7 @@ class UriAttachment : Attachment {
transferState = transferState,
size = size,
fileName = fileName,
cdnNumber = 0,
cdn = Cdn.CDN_0,
remoteLocation = null,
remoteKey = null,
remoteDigest = null,

View File

@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.OneTimePreKeyTable;
import org.thoughtcrime.securesms.database.PendingRetryReceiptTable;
import org.thoughtcrime.securesms.database.ReactionTable;
import org.thoughtcrime.securesms.database.RemappedRecordTables;
import org.thoughtcrime.securesms.database.SearchTable;
import org.thoughtcrime.securesms.database.SenderKeyTable;
import org.thoughtcrime.securesms.database.SenderKeySharedTable;
@@ -92,7 +93,9 @@ public class FullBackupExporter extends FullBackupBase {
SenderKeyTable.TABLE_NAME,
SenderKeySharedTable.TABLE_NAME,
PendingRetryReceiptTable.TABLE_NAME,
AvatarPickerDatabase.TABLE_NAME
AvatarPickerDatabase.TABLE_NAME,
RemappedRecordTables.Recipients.TABLE_NAME,
RemappedRecordTables.Threads.TABLE_NAME
);
public static BackupEvent export(@NonNull Context context,

View File

@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.profiles.AvatarHelper;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Util;
import java.io.ByteArrayOutputStream;

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup
import org.signal.core.util.LongSerializer
enum class RestoreState(val id: Int, val inProgress: Boolean) {
FAILED(-1, false),
NONE(0, false),
PENDING(1, true),
RESTORING_DB(2, true),
RESTORING_MEDIA(3, true);
companion object {
val serializer: LongSerializer<RestoreState> = Serializer()
}
class Serializer : LongSerializer<RestoreState> {
override fun serialize(data: RestoreState): Long {
return data.id.toLong()
}
override fun deserialize(data: Long): RestoreState {
return when (data.toInt()) {
FAILED.id -> FAILED
PENDING.id -> PENDING
RESTORING_DB.id -> RESTORING_DB
RESTORING_MEDIA.id -> RESTORING_MEDIA
else -> NONE
}
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
/**
* Describes how often a users messages are backed up.
*/
enum class BackupFrequency(val id: Int) {
DAILY(0),
WEEKLY(1),
MONTHLY(2),
MANUAL(-1);
companion object Serializer : LongSerializer<BackupFrequency> {
override fun serialize(data: BackupFrequency): Long {
return data.id.toLong()
}
override fun deserialize(data: Long): BackupFrequency {
return when (data.toInt()) {
MANUAL.id -> MANUAL
DAILY.id -> DAILY
WEEKLY.id -> WEEKLY
MONTHLY.id -> MONTHLY
else -> MANUAL
}
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2023 Signal Messenger, LLC
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -14,11 +14,14 @@ import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
import org.signal.libsignal.messagebackup.MessageBackupKey
import org.signal.libsignal.protocol.ServiceId.Aci
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
import org.thoughtcrime.securesms.backup.v2.processor.CallLogBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.AdHocCallBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
@@ -35,19 +38,25 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.io.OutputStream
import kotlin.time.Duration.Companion.milliseconds
object BackupRepository {
@@ -55,10 +64,15 @@ object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
private const val VERSION = 1L
fun export(plaintext: Boolean = false): ByteArray {
val eventTimer = EventTimer()
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
if (error.code == 401) {
Log.i(TAG, "Resetting initialized state due to 401.")
SignalStore.backup().backupsInitialized = false
}
}
val outputStream = ByteArrayOutputStream()
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
@@ -66,11 +80,11 @@ object BackupRepository {
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
append = append
)
}
val exportState = ExportState(System.currentTimeMillis())
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = true)
writer.use {
writer.write(
@@ -97,7 +111,7 @@ object BackupRepository {
eventTimer.emit("thread")
}
CallLogBackupProcessor.export { frame ->
AdHocCallBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("call")
}
@@ -110,7 +124,11 @@ object BackupRepository {
}
Log.d(TAG, "export() ${eventTimer.stop().summary}")
}
fun export(plaintext: Boolean = false): ByteArray {
val outputStream = ByteArrayOutputStream()
export(outputStream = outputStream, append = { mac -> outputStream.write(mac) }, plaintext = plaintext)
return outputStream.toByteArray()
}
@@ -124,11 +142,13 @@ object BackupRepository {
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val frameReader = if (plaintext) {
PlainTextBackupReader(inputStreamFactory())
} else {
EncryptedBackupReader(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
key = backupKey,
aci = selfData.aci,
streamLength = length,
dataStream = inputStreamFactory
@@ -160,7 +180,7 @@ object BackupRepository {
SignalDatabase.recipients.setProfileSharing(selfId, true)
eventTimer.emit("setup")
val backupState = BackupState()
val backupState = BackupState(backupKey)
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
for (frame in frameReader) {
@@ -180,8 +200,8 @@ object BackupRepository {
eventTimer.emit("chat")
}
frame.call != null -> {
CallLogBackupProcessor.import(frame.call, backupState)
frame.adHocCall != null -> {
AdHocCallBackupProcessor.import(frame.adHocCall, backupState)
eventTimer.emit("call")
}
@@ -215,6 +235,27 @@ object BackupRepository {
Log.d(TAG, "import() ${eventTimer.stop().summary}")
}
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getArchiveMediaItemsPage(backupKey, credential, limit, cursor)
}
}
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, credential)
.map { it.usedSpace }
}
}
/**
* Returns an object with details about the remote backup state.
*/
@@ -222,14 +263,7 @@ object BackupRepository {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential ->
api.setPublicKey(backupKey, credential)
.also { Log.i(TAG, "PublicKeyResult: $it") }
.map { credential }
}
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, credential)
.map { it to credential }
@@ -256,14 +290,7 @@ object BackupRepository {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential ->
api.setPublicKey(backupKey, credential)
.also { Log.i(TAG, "PublicKeyResult: $it") }
.map { credential }
}
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getMessageBackupUploadForm(backupKey, credential)
.also { Log.i(TAG, "UploadFormResult: $it") }
@@ -281,6 +308,22 @@ object BackupRepository {
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
}
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, credential)
}
.then { info -> getCdnReadCredentials(info.cdn ?: Cdn.CDN_3.cdnNumber).map { it.headers to info } }
.map { pair ->
val (cdnCredentials, info) = pair
val messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver()
messageReceiver.retrieveBackup(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}", destination, listener)
} is NetworkResult.Success
}
/**
* Returns an object with details about the remote backup state.
*/
@@ -288,60 +331,122 @@ object BackupRepository {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.debugGetUploadedMediaItemMetadata(backupKey, credential)
}
}
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
/**
* Retrieves an upload spec that can be used to upload attachment media.
*/
fun getMediaUploadSpec(): NetworkResult<ResumableUploadSpec> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
item = attachment.toArchiveMediaRequest(backupKey)
)
api.getMediaUploadForm(backupKey, credential)
}
.then { form ->
api.getResumableUploadSpec(form)
}
.also { Log.i(TAG, "backupMediaResult: $it") }
}
fun archiveMedia(attachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResponse> {
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
items = attachments.map { it.toArchiveMediaRequest(backupKey) }
item = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
)
}
.also { Log.i(TAG, "backupMediaResult: $it") }
}
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
val mediaName = attachment.getMediaName()
val request = attachment.toArchiveMediaRequest(mediaName, backupKey)
api
.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
item = request
)
.map { Triple(mediaName, request.mediaId, it) }
}
.map { (mediaName, mediaId, response) ->
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId)
}
.also { Log.i(TAG, "archiveMediaResult: $it") }
}
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
val requests = mutableListOf<ArchiveMediaRequest>()
val mediaIdToAttachmentId = mutableMapOf<String, AttachmentId>()
val attachmentIdToMediaName = mutableMapOf<AttachmentId, String>()
databaseAttachments.forEach {
val mediaName = it.getMediaName()
val request = it.toArchiveMediaRequest(mediaName, backupKey)
requests += request
mediaIdToAttachmentId[request.mediaId] = it.attachmentId
attachmentIdToMediaName[it.attachmentId] = mediaName.name
}
api
.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
items = requests
)
.map { BatchArchiveMediaResult(it, mediaIdToAttachmentId, attachmentIdToMediaName) }
}
.map { result ->
result
.successfulResponses
.forEach {
val attachmentId = result.mediaIdToAttachmentId(it.mediaId)
val mediaName = result.attachmentIdToMediaName(attachmentId)
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId)
}
result
}
.also { Log.i(TAG, "archiveMediaResult: $it") }
}
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = attachments.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = 3, // TODO [cody] store and reuse backup cdn returned from copy/move call
mediaId = backupKey.deriveMediaId(Base64.decode(it.dataHash!!)).toString()
)
val mediaToDelete = attachments
.filter { it.archiveMediaId != null }
.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = it.archiveCdn,
mediaId = it.archiveMediaId!!
)
}
if (mediaToDelete.isEmpty()) {
Log.i(TAG, "No media to delete, quick success")
return NetworkResult.Success(Unit)
}
return getAuthCredential()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.deleteArchivedMedia(
backupKey = backupKey,
@@ -349,7 +454,155 @@ object BackupRepository {
mediaToDelete = mediaToDelete
)
}
.also { Log.i(TAG, "deleteBackupMediaResult: $it") }
.map {
SignalDatabase.attachments.clearArchiveData(attachments.map { it.attachmentId })
}
.also { Log.i(TAG, "deleteArchivedMediaResult: $it") }
}
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = mediaObjects
.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = it.cdn,
mediaId = it.mediaId
)
}
if (mediaToDelete.isEmpty()) {
Log.i(TAG, "No media to delete, quick success")
return NetworkResult.Success(Unit)
}
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.deleteArchivedMedia(
backupKey = backupKey,
serviceCredential = credential,
mediaToDelete = mediaToDelete
)
}
.also { Log.i(TAG, "deleteAbandonedMediaObjectsResult: $it") }
}
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return debugGetArchivedMediaState()
.then { archivedMedia ->
val mediaToDelete = archivedMedia
.map {
DeleteArchivedMediaRequest.ArchivedMediaObject(
cdn = it.cdn,
mediaId = it.mediaId
)
}
if (mediaToDelete.isEmpty()) {
Log.i(TAG, "No media to delete, quick success")
NetworkResult.Success(Unit)
} else {
getAuthCredential()
.then { credential ->
api.deleteArchivedMedia(
backupKey = backupKey,
serviceCredential = credential,
mediaToDelete = mediaToDelete
)
}
}
}
.map {
SignalDatabase.attachments.clearAllArchiveData()
}
.also { Log.i(TAG, "debugDeleteAllArchivedMediaResult: $it") }
}
/**
* Retrieve credentials for reading from the backup cdn.
*/
fun getCdnReadCredentials(cdnNumber: Int): NetworkResult<GetArchiveCdnCredentialsResponse> {
val cached = SignalStore.backup().cdnReadCredentials
if (cached != null) {
return NetworkResult.Success(cached)
}
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getCdnReadCredentials(
cdnNumber = cdnNumber,
backupKey = backupKey,
serviceCredential = credential
)
}
.also {
if (it is NetworkResult.Success) {
SignalStore.backup().cdnReadCredentials = it.result
}
}
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
}
/**
* Retrieves backupDir and mediaDir, preferring cached value if available.
*
* These will only ever change if the backup expires.
*/
fun getCdnBackupDirectories(): NetworkResult<BackupDirectories> {
val cachedBackupDirectory = SignalStore.backup().cachedBackupDirectory
val cachedBackupMediaDirectory = SignalStore.backup().cachedBackupMediaDirectory
if (cachedBackupDirectory != null && cachedBackupMediaDirectory != null) {
return NetworkResult.Success(
BackupDirectories(
backupDir = cachedBackupDirectory,
mediaDir = cachedBackupMediaDirectory
)
)
}
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, credential).map {
SignalStore.backup().usedBackupMediaSpace = it.usedSpace ?: 0L
BackupDirectories(it.backupDir!!, it.mediaDir!!)
}
}
.also {
if (it is NetworkResult.Success) {
SignalStore.backup().cachedBackupDirectory = it.result.backupDir
SignalStore.backup().cachedBackupMediaDirectory = it.result.mediaDir
}
}
}
/**
* Ensures that the backupId has been reserved and that your public key has been set, while also returning an auth credential.
* Should be the basis of all backup operations.
*/
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
return if (SignalStore.backup().backupsInitialized) {
getAuthCredential().runOnStatusCodeError(resetInitializedStateErrorAction)
} else {
return api
.triggerBackupIdReservation(backupKey)
.then { getAuthCredential() }
.then { credential -> api.setPublicKey(backupKey, credential).map { credential } }
.runIfSuccessful { SignalStore.backup().backupsInitialized = true }
.runOnStatusCodeError(resetInitializedStateErrorAction)
}
}
/**
@@ -380,15 +633,24 @@ object BackupRepository {
val profileKey: ProfileKey
)
private fun DatabaseAttachment.toArchiveMediaRequest(backupKey: BackupKey): ArchiveMediaRequest {
val mediaSecrets = backupKey.deriveMediaSecrets(Base64.decode(dataHash!!))
fun DatabaseAttachment.getMediaName(): MediaName {
return MediaName.fromDigest(remoteDigest!!)
}
fun DatabaseAttachment.getThumbnailMediaName(): MediaName {
return MediaName.fromDigestForThumbnail(remoteDigest!!)
}
private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, backupKey: BackupKey): ArchiveMediaRequest {
val mediaSecrets = backupKey.deriveMediaSecrets(mediaName)
return ArchiveMediaRequest(
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
cdn = cdnNumber,
cdn = cdn.cdnNumber,
key = remoteLocation!!
),
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
mediaId = mediaSecrets.id.toString(),
mediaId = mediaSecrets.id.encode(),
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
encryptionKey = Base64.encodeWithPadding(mediaSecrets.cipherKey),
iv = Base64.encodeWithPadding(mediaSecrets.iv)
@@ -396,20 +658,28 @@ object BackupRepository {
}
}
class ExportState(val backupTime: Long) {
data class ArchivedMediaObject(val mediaId: String, val cdn: Int)
data class BackupDirectories(val backupDir: String, val mediaDir: String)
class ExportState(val backupTime: Long, val allowMediaBackup: Boolean) {
val recipientIds = HashSet<Long>()
val threadIds = HashSet<Long>()
}
class BackupState {
class BackupState(val backupKey: BackupKey) {
val backupToLocalRecipientId = HashMap<Long, RecipientId>()
val chatIdToLocalThreadId = HashMap<Long, Long>()
val chatIdToLocalRecipientId = HashMap<Long, RecipientId>()
val chatIdToBackupRecipientId = HashMap<Long, Long>()
val callIdToType = HashMap<Long, Long>()
}
class BackupMetadata(
val usedSpace: Long,
val mediaCount: Long
)
enum class MessageBackupTier {
FREE,
PAID
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob
/**
* Responsible for managing logic around restore prioritization
*/
object BackupRestoreManager {
private val reprioritizedAttachments: HashSet<AttachmentId> = HashSet()
/**
* Raise priority of all attachments for the included message records.
*
* This is so we can make certain attachments get downloaded more quickly
*/
fun prioritizeAttachmentsIfNeeded(messageRecords: List<MessageRecord>) {
SignalExecutors.BOUNDED.execute {
synchronized(this) {
val restoringAttachments: List<AttachmentId> = messageRecords
.mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides }
.flatten()
.mapNotNull { it.asAttachment() as? DatabaseAttachment }
.filter { it.transferState == AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS && !reprioritizedAttachments.contains(it.attachmentId) }
.map { it.attachmentId }
reprioritizedAttachments += restoringAttachments
if (restoringAttachments.isNotEmpty()) {
RestoreAttachmentJob.modifyPriorities(restoringAttachments.toSet(), 1)
}
}
}
}
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
class BackupV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
enum class Type {
PROGRESS_MESSAGES, PROGRESS_ATTACHMENTS, FINISHED
}
}

View File

@@ -0,0 +1,39 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
/**
* Result of attempting to batch copy multiple attachments at once with helpers for
* processing the collection of mini-responses.
*/
data class BatchArchiveMediaResult(
private val response: BatchArchiveMediaResponse,
private val mediaIdToAttachmentId: Map<String, AttachmentId>,
private val attachmentIdToMediaName: Map<AttachmentId, String>
) {
val successfulResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
get() = response
.responses
.asSequence()
.filter { it.status == 200 }
val sourceNotFoundResponses: Sequence<BatchArchiveMediaResponse.BatchArchiveMediaItemResponse>
get() = response
.responses
.asSequence()
.filter { it.status == 410 }
fun mediaIdToAttachmentId(mediaId: String): AttachmentId {
return mediaIdToAttachmentId[mediaId]!!
}
fun attachmentIdToMediaName(attachmentId: AttachmentId): String {
return attachmentIdToMediaName[attachmentId]!!
}
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.select
import org.signal.ringrtc.CallLinkRootKey
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.backup.v2.proto.CallLink
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import java.io.Closeable
import java.time.Instant
fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator {
val cursor = readableDatabase
.select()
.from(CallLinkTable.TABLE_NAME)
.run()
return BackupCallLinkIterator(cursor)
}
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId {
return SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(callLink.rootKey.toByteArray())),
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
state = SignalCallLinkState(
name = callLink.name,
restrictions = callLink.restrictions.toLocal(),
expiration = Instant.ofEpochMilli(callLink.expirationMs)
)
)
)
}
/**
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
*/
class BackupCallLinkIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): BackupRecipient {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
val callLink = CallLinkTable.CallLinkDeserializer.deserialize(cursor)
return BackupRecipient(
id = callLink.recipientId.toLong(),
callLink = CallLink(
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
adminKey = callLink.credentials.adminPassBytes?.toByteString(),
name = callLink.state.name,
expirationMs = callLink.state.expiration.toEpochMilli(),
restrictions = callLink.state.restrictions.toBackup()
)
)
}
override fun close() {
cursor.close()
}
}
private fun CallLinkState.Restrictions.toBackup(): CallLink.Restrictions {
return when (this) {
CallLinkState.Restrictions.ADMIN_APPROVAL -> CallLink.Restrictions.ADMIN_APPROVAL
CallLinkState.Restrictions.NONE -> CallLink.Restrictions.NONE
CallLinkState.Restrictions.UNKNOWN -> CallLink.Restrictions.UNKNOWN
}
}
private fun CallLink.Restrictions.toLocal(): CallLinkState.Restrictions {
return when (this) {
CallLink.Restrictions.ADMIN_APPROVAL -> CallLinkState.Restrictions.ADMIN_APPROVAL
CallLink.Restrictions.NONE -> CallLinkState.Restrictions.NONE
CallLink.Restrictions.UNKNOWN -> CallLinkState.Restrictions.UNKNOWN
}
}

View File

@@ -8,57 +8,37 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase
import androidx.core.content.contentValuesOf
import org.signal.core.util.isNull
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.select
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.Call
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.RecipientTable
import java.io.Closeable
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
fun CallTable.getCallsForBackup(): CallLogIterator {
fun CallTable.getAdhocCallsForBackup(): CallLogIterator {
return CallLogIterator(
readableDatabase
.select()
.from(CallTable.TABLE_NAME)
.where("${CallTable.EVENT} != ${CallTable.Event.serialize(CallTable.Event.DELETE)}")
.where("${CallTable.TYPE}=?", CallTable.Type.AD_HOC_CALL)
.run()
)
}
fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupState) {
val type = when (call.type) {
Call.Type.VIDEO_CALL -> CallTable.Type.VIDEO_CALL
Call.Type.AUDIO_CALL -> CallTable.Type.AUDIO_CALL
Call.Type.AD_HOC_CALL -> CallTable.Type.AD_HOC_CALL
Call.Type.GROUP_CALL -> CallTable.Type.GROUP_CALL
Call.Type.UNKNOWN_TYPE -> return
}
fun CallTable.restoreCallLogFromBackup(call: AdHocCall, backupState: BackupState) {
val event = when (call.state) {
Call.State.MISSED -> CallTable.Event.MISSED
Call.State.COMPLETED -> CallTable.Event.ACCEPTED
Call.State.DECLINED_BY_USER -> CallTable.Event.DECLINED
Call.State.DECLINED_BY_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
Call.State.UNKNOWN_EVENT -> return
AdHocCall.State.GENERIC -> CallTable.Event.GENERIC_GROUP_CALL
AdHocCall.State.UNKNOWN_STATE -> CallTable.Event.GENERIC_GROUP_CALL
}
val direction = if (call.outgoing) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING
backupState.callIdToType[call.callId] = CallTable.Call.getMessageType(type, direction, event)
val values = contentValuesOf(
CallTable.CALL_ID to call.callId,
CallTable.PEER to backupState.backupToLocalRecipientId[call.conversationRecipientId]!!.serialize(),
CallTable.TYPE to CallTable.Type.serialize(type),
CallTable.DIRECTION to CallTable.Direction.serialize(direction),
CallTable.PEER to backupState.backupToLocalRecipientId[call.recipientId]!!.serialize(),
CallTable.TYPE to CallTable.Type.serialize(CallTable.Type.AD_HOC_CALL),
CallTable.DIRECTION to CallTable.Direction.serialize(CallTable.Direction.OUTGOING),
CallTable.EVENT to CallTable.Event.serialize(event),
CallTable.TIMESTAMP to call.timestamp,
CallTable.RINGER to if (call.ringerRecipientId != null) backupState.backupToLocalRecipientId[call.ringerRecipientId]?.toLong() else null
CallTable.TIMESTAMP to call.startedCallTimestamp
)
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
@@ -68,49 +48,23 @@ fun CallTable.restoreCallLogFromBackup(call: BackupCall, backupState: BackupStat
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
*/
class CallLogIterator(private val cursor: Cursor) : Iterator<BackupCall?>, Closeable {
class CallLogIterator(private val cursor: Cursor) : Iterator<AdHocCall?>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): BackupCall? {
override fun next(): AdHocCall? {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
val callId = cursor.requireLong(CallTable.CALL_ID)
val type = CallTable.Type.deserialize(cursor.requireInt(CallTable.TYPE))
val direction = CallTable.Direction.deserialize(cursor.requireInt(CallTable.DIRECTION))
val event = CallTable.Event.deserialize(cursor.requireInt(CallTable.EVENT))
return BackupCall(
return AdHocCall(
callId = callId,
conversationRecipientId = cursor.requireLong(CallTable.PEER),
type = when (type) {
CallTable.Type.AUDIO_CALL -> Call.Type.AUDIO_CALL
CallTable.Type.VIDEO_CALL -> Call.Type.VIDEO_CALL
CallTable.Type.AD_HOC_CALL -> Call.Type.AD_HOC_CALL
CallTable.Type.GROUP_CALL -> Call.Type.GROUP_CALL
},
outgoing = when (direction) {
CallTable.Direction.OUTGOING -> true
else -> false
},
timestamp = cursor.requireLong(CallTable.TIMESTAMP),
ringerRecipientId = if (cursor.isNull(CallTable.RINGER)) null else cursor.requireLong(CallTable.RINGER),
state = when (event) {
CallTable.Event.ONGOING -> Call.State.COMPLETED
CallTable.Event.OUTGOING_RING -> Call.State.COMPLETED
CallTable.Event.ACCEPTED -> Call.State.COMPLETED
CallTable.Event.DECLINED -> Call.State.DECLINED_BY_USER
CallTable.Event.GENERIC_GROUP_CALL -> Call.State.COMPLETED
CallTable.Event.JOINED -> Call.State.COMPLETED
CallTable.Event.MISSED -> Call.State.MISSED
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> Call.State.DECLINED_BY_NOTIFICATION_PROFILE
CallTable.Event.DELETE -> Call.State.COMPLETED
CallTable.Event.RINGING -> Call.State.MISSED
CallTable.Event.NOT_ACCEPTED -> Call.State.MISSED
}
recipientId = cursor.requireLong(CallTable.PEER),
state = AdHocCall.State.GENERIC,
startedCallTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
)
}

View File

@@ -6,7 +6,6 @@
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import com.annimon.stream.Stream
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decode
@@ -17,14 +16,15 @@ import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.proto.CallChatUpdate
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getMediaName
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
@@ -36,6 +36,8 @@ import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.Text
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
@@ -63,7 +65,6 @@ import java.io.Closeable
import java.io.IOException
import java.util.LinkedList
import java.util.Queue
import java.util.UUID
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
/**
@@ -73,7 +74,7 @@ import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
*
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
*/
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int) : Iterator<ChatItem>, Closeable {
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int, private val archiveMedia: Boolean) : Iterator<ChatItem>, Closeable {
companion object {
private val TAG = Log.tag(ChatItemExportIterator::class.java)
@@ -136,9 +137,10 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageTypes.isPaymentsRequestToActivate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST))
MessageTypes.isExpirationTimerUpdate(record.type) -> {
builder.updateMessage = ChatUpdateMessage(expirationTimerChange = ExpirationTimerChatUpdate(record.expiresIn.toInt()))
builder.expiresInMs = null
builder.expiresInMs = 0
}
MessageTypes.isProfileChange(record.type) -> {
if (record.body == null) continue
builder.updateMessage = ChatUpdateMessage(
profileChange = try {
val decoded: ByteArray = Base64.decode(record.body!!)
@@ -196,45 +198,115 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
MessageTypes.isCallLog(record.type) -> {
builder.sms = false
val call = calls.getCallByMessageId(record.id)
if (call != null) {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callId = call.callId))
if (call.type == CallTable.Type.GROUP_CALL) {
builder.updateMessage = ChatUpdateMessage(
groupCall = GroupCall(
callId = record.id,
state = when (call.event) {
CallTable.Event.MISSED -> GroupCall.State.MISSED
CallTable.Event.ONGOING -> GroupCall.State.GENERIC
CallTable.Event.ACCEPTED -> GroupCall.State.ACCEPTED
CallTable.Event.NOT_ACCEPTED -> GroupCall.State.GENERIC
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> GroupCall.State.MISSED_NOTIFICATION_PROFILE
CallTable.Event.DELETE -> continue
CallTable.Event.GENERIC_GROUP_CALL -> GroupCall.State.GENERIC
CallTable.Event.JOINED -> GroupCall.State.JOINED
CallTable.Event.RINGING -> GroupCall.State.RINGING
CallTable.Event.DECLINED -> GroupCall.State.DECLINED
CallTable.Event.OUTGOING_RING -> GroupCall.State.OUTGOING_RING
},
ringerRecipientId = call.ringerRecipient?.toLong(),
startedCallAci = if (call.ringerRecipient != null) SignalDatabase.recipients.getRecord(call.ringerRecipient).aci?.toByteString() else null,
startedCallTimestamp = call.timestamp
)
)
} else if (call.type != CallTable.Type.AD_HOC_CALL) {
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
callId = call.callId,
type = if (call.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL,
direction = if (call.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING,
state = when (call.event) {
CallTable.Event.MISSED -> IndividualCall.State.MISSED
CallTable.Event.MISSED_NOTIFICATION_PROFILE -> IndividualCall.State.MISSED_NOTIFICATION_PROFILE
CallTable.Event.ACCEPTED -> IndividualCall.State.ACCEPTED
CallTable.Event.NOT_ACCEPTED -> IndividualCall.State.NOT_ACCEPTED
else -> IndividualCall.State.UNKNOWN_STATE
},
startedCallTimestamp = call.timestamp
)
)
} else {
continue
}
} else {
when {
MessageTypes.isMissedAudioCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.MISSED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isMissedVideoCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.MISSED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isIncomingAudioCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isIncomingVideoCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isOutgoingAudioCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.OUTGOING
)
)
}
MessageTypes.isOutgoingVideoCall(record.type) -> {
builder.updateMessage = ChatUpdateMessage(callingMessage = CallChatUpdate(callMessage = IndividualCallChatUpdate(type = IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL)))
builder.updateMessage = ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.OUTGOING
)
)
}
MessageTypes.isGroupCall(record.type) -> {
try {
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(record.body)
val joinedMembers = Stream.of(groupCallUpdateDetails.inCallUuids)
.map { uuid: String? -> UuidUtil.parseOrNull(uuid) }
.withoutNulls()
.map { obj: UUID? -> ACI.from(obj!!).toByteString() }
.toList()
builder.updateMessage = ChatUpdateMessage(
callingMessage = CallChatUpdate(
groupCall = GroupCallChatUpdate(
startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(),
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
inCallAcis = joinedMembers
)
groupCall = GroupCall(
state = GroupCall.State.GENERIC,
startedCallAci = ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid)).toByteString(),
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
)
)
} catch (exception: java.lang.Exception) {
@@ -282,12 +354,13 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
chatId = record.threadId
authorId = record.fromRecipientId
dateSent = record.dateSent
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
expireStartDate = if (record.expireStarted > 0) record.expireStarted else 0
expiresInMs = if (record.expiresIn > 0) record.expiresIn else 0
revisions = emptyList()
sms = !MessageTypes.isSecureType(record.type)
if (MessageTypes.isOutgoingMessageType(record.type)) {
if (MessageTypes.isCallLog(record.type)) {
directionless = ChatItem.DirectionlessMessageDetails()
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
outgoing = ChatItem.OutgoingMessageDetails(
sendStatus = record.toBackupSendStatus(groupReceipts)
)
@@ -354,24 +427,46 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
private fun DatabaseAttachment.toBackupAttachment(): MessageAttachment {
val builder = FilePointer.Builder()
builder.contentType = contentType
builder.incrementalMac = incrementalDigest?.toByteString()
builder.incrementalMacChunkSize = incrementalMacChunkSize
builder.fileName = fileName
builder.width = width
builder.height = height
builder.caption = caption
builder.blurHash = blurHash?.hash
if (remoteKey.isNullOrBlank() || remoteDigest == null || size == 0L) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
} else {
if (archiveMedia) {
builder.backupLocator = FilePointer.BackupLocator(
mediaName = archiveMediaName ?: this.getMediaName().toString(),
cdnNumber = if (archiveMediaName != null) archiveCdn else Cdn.CDN_3.cdnNumber, // TODO (clark): Update when new proto with optional cdn is landed
key = decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = remoteDigest.toByteString()
)
} else {
if (remoteLocation.isNullOrBlank()) {
builder.invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator()
} else {
builder.attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = this.remoteLocation,
cdnNumber = this.cdn.cdnNumber,
uploadTimestamp = this.uploadTimestamp,
key = decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = remoteDigest.toByteString()
)
}
}
}
return MessageAttachment(
pointer = FilePointer(
attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = this.remoteLocation ?: "",
cdnNumber = this.cdnNumber,
uploadTimestamp = this.uploadTimestamp
),
key = if (remoteKey != null) decode(remoteKey).toByteString() else null,
contentType = this.contentType,
size = this.size.toInt(),
incrementalMac = this.incrementalDigest?.toByteString(),
incrementalMacChunkSize = this.incrementalMacChunkSize,
fileName = this.fileName,
width = this.width,
height = this.height,
caption = this.caption,
blurHash = this.blurHash?.hash
)
pointer = builder.build(),
wasDownloaded = this.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || this.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE,
flag = if (voiceNote) MessageAttachment.Flag.VOICE_MESSAGE else if (videoGif) MessageAttachment.Flag.GIF else if (borderless) MessageAttachment.Flag.BORDERLESS else MessageAttachment.Flag.NONE
)
}

View File

@@ -13,19 +13,24 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.attachments.ArchivedAttachment
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.attachments.TombstoneAttachment
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCallChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
@@ -37,6 +42,7 @@ import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
import org.thoughtcrime.securesms.database.documents.NetworkFailure
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.GroupCallUpdateDetailsUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
@@ -48,11 +54,12 @@ import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.DataMessage
import java.util.Optional
/**
@@ -96,7 +103,8 @@ class ChatItemImportInserter(
MessageTable.SHARED_CONTACTS,
MessageTable.LINK_PREVIEWS,
MessageTable.MESSAGE_RANGES,
MessageTable.VIEW_ONCE
MessageTable.VIEW_ONCE,
MessageTable.MESSAGE_EXTRAS
)
private val REACTION_COLUMNS = arrayOf(
@@ -208,11 +216,53 @@ class ChatItemImportInserter(
var followUp: ((Long) -> Unit)? = null
if (this.updateMessage != null) {
if (this.updateMessage.callingMessage != null && this.updateMessage.callingMessage.callId != null) {
if (this.updateMessage.individualCall != null && this.updateMessage.individualCall.callId != null) {
followUp = { messageRowId ->
val callContentValues = ContentValues()
callContentValues.put(CallTable.MESSAGE_ID, messageRowId)
db.update(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, callContentValues, "${CallTable.CALL_ID} = ?", SqlUtil.buildArgs(this.updateMessage.callingMessage.callId))
val values = contentValuesOf(
CallTable.CALL_ID to updateMessage.individualCall.callId,
CallTable.MESSAGE_ID to messageRowId,
CallTable.PEER to chatRecipientId.serialize(),
CallTable.TYPE to CallTable.Type.serialize(if (updateMessage.individualCall.type == IndividualCall.Type.VIDEO_CALL) CallTable.Type.VIDEO_CALL else CallTable.Type.AUDIO_CALL),
CallTable.DIRECTION to CallTable.Direction.serialize(if (updateMessage.individualCall.direction == IndividualCall.Direction.OUTGOING) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING),
CallTable.EVENT to CallTable.Event.serialize(
when (updateMessage.individualCall.state) {
IndividualCall.State.MISSED -> CallTable.Event.MISSED
IndividualCall.State.MISSED_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
IndividualCall.State.ACCEPTED -> CallTable.Event.ACCEPTED
IndividualCall.State.NOT_ACCEPTED -> CallTable.Event.NOT_ACCEPTED
else -> CallTable.Event.MISSED
}
),
CallTable.TIMESTAMP to updateMessage.individualCall.startedCallTimestamp,
CallTable.READ to CallTable.ReadState.serialize(CallTable.ReadState.UNREAD)
)
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
}
} else if (this.updateMessage.groupCall != null && this.updateMessage.groupCall.callId != null) {
followUp = { messageRowId ->
val values = contentValuesOf(
CallTable.CALL_ID to updateMessage.groupCall.callId,
CallTable.MESSAGE_ID to messageRowId,
CallTable.PEER to chatRecipientId.serialize(),
CallTable.TYPE to CallTable.Type.serialize(CallTable.Type.GROUP_CALL),
CallTable.DIRECTION to CallTable.Direction.serialize(if (backupState.backupToLocalRecipientId[updateMessage.groupCall.ringerRecipientId] == selfId) CallTable.Direction.OUTGOING else CallTable.Direction.INCOMING),
CallTable.EVENT to CallTable.Event.serialize(
when (updateMessage.groupCall.state) {
GroupCall.State.ACCEPTED -> CallTable.Event.ACCEPTED
GroupCall.State.MISSED -> CallTable.Event.MISSED
GroupCall.State.MISSED_NOTIFICATION_PROFILE -> CallTable.Event.MISSED_NOTIFICATION_PROFILE
GroupCall.State.GENERIC -> CallTable.Event.GENERIC_GROUP_CALL
GroupCall.State.JOINED -> CallTable.Event.JOINED
GroupCall.State.RINGING -> CallTable.Event.RINGING
GroupCall.State.OUTGOING_RING -> CallTable.Event.OUTGOING_RING
GroupCall.State.DECLINED -> CallTable.Event.DECLINED
else -> CallTable.Event.GENERIC_GROUP_CALL
}
),
CallTable.TIMESTAMP to updateMessage.groupCall.startedCallTimestamp,
CallTable.READ to CallTable.ReadState.serialize(CallTable.ReadState.UNREAD)
)
db.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
}
}
}
@@ -437,28 +487,22 @@ class ChatItemImportInserter(
val threadMergeDetails = ThreadMergeEvent(previousE164 = updateMessage.threadMerge.previousE164.toString()).encode()
put(MessageTable.BODY, Base64.encodeWithPadding(threadMergeDetails))
}
updateMessage.callingMessage != null -> {
when {
updateMessage.callingMessage.callId != null -> {
typeFlags = backupState.callIdToType[updateMessage.callingMessage.callId]!!
}
updateMessage.callingMessage.callMessage != null -> {
typeFlags = when (updateMessage.callingMessage.callMessage.type) {
IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL -> MessageTypes.INCOMING_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL -> MessageTypes.INCOMING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL -> MessageTypes.MISSED_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL -> MessageTypes.MISSED_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_AUDIO_CALL -> MessageTypes.OUTGOING_AUDIO_CALL_TYPE
IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_VIDEO_CALL -> MessageTypes.OUTGOING_VIDEO_CALL_TYPE
IndividualCallChatUpdate.Type.UNKNOWN -> typeFlags
}
updateMessage.individualCall != null -> {
if (updateMessage.individualCall.state == IndividualCall.State.MISSED || updateMessage.individualCall.state == IndividualCall.State.MISSED_NOTIFICATION_PROFILE) {
typeFlags = if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.MISSED_AUDIO_CALL_TYPE else MessageTypes.MISSED_VIDEO_CALL_TYPE
} else {
typeFlags = if (updateMessage.individualCall.direction == IndividualCall.Direction.OUTGOING) {
if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.OUTGOING_AUDIO_CALL_TYPE else MessageTypes.OUTGOING_VIDEO_CALL_TYPE
} else {
if (updateMessage.individualCall.type == IndividualCall.Type.AUDIO_CALL) MessageTypes.INCOMING_AUDIO_CALL_TYPE else MessageTypes.INCOMING_VIDEO_CALL_TYPE
}
}
// Calls don't use the incoming/outgoing flags, so we overwrite the flags here
this.put(MessageTable.TYPE, typeFlags)
}
updateMessage.groupCall != null -> {
this.put(MessageTable.BODY, GroupCallUpdateDetailsUtil.createBodyFromBackup(updateMessage.groupCall))
this.put(MessageTable.TYPE, MessageTypes.GROUP_CALL_TYPE)
}
updateMessage.groupChange != null -> {
put(MessageTable.BODY, "")
put(
@@ -570,12 +614,12 @@ class ChatItemImportInserter(
pointer.attachmentLocator.cdnNumber,
SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey),
contentType,
pointer.key?.toByteArray(),
Optional.ofNullable(pointer.size),
pointer.attachmentLocator.key.toByteArray(),
Optional.ofNullable(pointer.attachmentLocator.size),
Optional.empty(),
pointer.width ?: 0,
pointer.height ?: 0,
Optional.empty(),
Optional.ofNullable(pointer.attachmentLocator.digest.toByteArray()),
Optional.ofNullable(pointer.incrementalMac?.toByteArray()),
pointer.incrementalMacChunkSize ?: 0,
Optional.ofNullable(fileName),
@@ -586,14 +630,53 @@ class ChatItemImportInserter(
Optional.ofNullable(pointer.blurHash),
pointer.attachmentLocator.uploadTimestamp
)
return PointerAttachment.forPointer(Optional.of(signalAttachmentPointer)).orNull()
return PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
).orNull()
} else if (pointer.invalidAttachmentLocator != null) {
return TombstoneAttachment(
contentType = contentType,
incrementalMac = pointer.incrementalMac?.toByteArray(),
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
width = pointer.width,
height = pointer.height,
caption = pointer.caption,
blurHash = pointer.blurHash,
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
gif = flag == MessageAttachment.Flag.GIF,
quote = false
)
} else if (pointer.backupLocator != null) {
return ArchivedAttachment(
contentType = contentType,
size = pointer.backupLocator.size.toLong(),
cdn = pointer.backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
key = pointer.backupLocator.key.toByteArray(),
cdnKey = pointer.backupLocator.transitCdnKey,
archiveCdn = pointer.backupLocator.cdnNumber,
archiveMediaName = pointer.backupLocator.mediaName,
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(pointer.backupLocator.mediaName)).encode(),
digest = pointer.backupLocator.digest.toByteArray(),
incrementalMac = pointer.incrementalMac?.toByteArray(),
incrementalMacChunkSize = pointer.incrementalMacChunkSize,
width = pointer.width,
height = pointer.height,
caption = pointer.caption,
blurHash = pointer.blurHash,
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
gif = flag == MessageAttachment.Flag.GIF,
quote = false
)
}
return null
}
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
?: if (this.contentType == null) null else PointerAttachment.forPointer(SignalServiceDataMessage.Quote.QuotedAttachment(contentType = this.contentType!!, fileName = this.fileName, thumbnail = null)).orNull()
?: if (this.contentType == null) null else PointerAttachment.forPointer(quotedAttachment = DataMessage.Quote.QuotedAttachment(contentType = this.contentType, fileName = this.fileName, thumbnail = null)).orNull()
}
private class MessageInsert(val contentValues: ContentValues, val followUp: ((Long) -> Unit)?)

View File

@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
private val TAG = Log.tag(MessageTable::class.java)
private const val BASE_TYPE = "base_type"
fun MessageTable.getMessagesForBackup(backupTime: Long): ChatItemExportIterator {
fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean): ChatItemExportIterator {
val cursor = readableDatabase
.select(
MessageTable.ID,
@@ -64,7 +64,7 @@ fun MessageTable.getMessagesForBackup(backupTime: Long): ChatItemExportIterator
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
.run()
return ChatItemExportIterator(cursor, 100)
return ChatItemExportIterator(cursor, 100, archiveMedia)
}
fun MessageTable.createChatItemInserter(backupState: BackupState): ChatItemImportInserter {

View File

@@ -13,6 +13,7 @@ import org.signal.core.util.SqlUtil
import org.signal.core.util.deleteAll
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
@@ -23,8 +24,16 @@ import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
import org.signal.storageservice.protos.groups.AccessControl
import org.signal.storageservice.protos.groups.Member
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember
import org.signal.storageservice.protos.groups.local.DecryptedGroup
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.signal.storageservice.protos.groups.local.DecryptedMember
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember
import org.signal.storageservice.protos.groups.local.DecryptedTimer
import org.signal.storageservice.protos.groups.local.EnabledState
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.Group
@@ -44,6 +53,8 @@ import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.util.toByteArray
@@ -103,7 +114,8 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
"${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}",
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}",
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}"
"${GroupTable.TABLE_NAME}.${GroupTable.TITLE}",
"${GroupTable.TABLE_NAME}.${GroupTable.V2_DECRYPTED_GROUP}"
)
.from(
"""
@@ -117,25 +129,6 @@ fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
return BackupGroupIterator(cursor)
}
/**
* Takes a [BackupRecipient] and writes it into the database.
*/
fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backupState: BackupState): RecipientId? {
// TODO Need to handle groups
// TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions
return when {
recipient.contact != null -> restoreContactFromBackup(recipient.contact)
recipient.group != null -> restoreGroupFromBackup(recipient.group)
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
recipient.self != null -> Recipient.self().id
recipient.releaseNotes != null -> restoreReleaseNotes()
else -> {
Log.w(TAG, "Unrecognized recipient type!")
null
}
}
}
/**
* Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable].
*/
@@ -175,7 +168,7 @@ fun RecipientTable.clearAllDataForBackupRestore() {
ApplicationDependencies.getRecipientCache().clearSelf()
}
private fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
val id = getAndPossiblyMergePnpVerified(
aci = ACI.parseOrNull(contact.aci?.toByteArray()),
pni = PNI.parseOrNull(contact.pni?.toByteArray()),
@@ -206,7 +199,7 @@ private fun RecipientTable.restoreContactFromBackup(contact: Contact): Recipient
return id
}
private fun RecipientTable.restoreReleaseNotes(): RecipientId {
fun RecipientTable.restoreReleaseNotes(): RecipientId {
val releaseChannelId: RecipientId = insertReleaseChannelRecipient()
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId)
@@ -215,13 +208,16 @@ private fun RecipientTable.restoreReleaseNotes(): RecipientId {
return releaseChannelId
}
private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
val masterKey = GroupMasterKey(group.masterKey.toByteArray())
val groupId = GroupId.v2(masterKey)
val placeholderState = DecryptedGroup.Builder()
.revision(GroupsV2StateProcessor.PLACEHOLDER_REVISION)
.build()
val operations = ApplicationDependencies.getGroupsV2Operations().forGroup(GroupSecretParams.deriveFromMasterKey(masterKey))
val decryptedState = if (group.snapshot == null) {
DecryptedGroup(revision = GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION)
} else {
group.snapshot.toDecryptedGroup(operations)
}
val values = ContentValues().apply {
put(RecipientTable.GROUP_ID, groupId.toString())
@@ -236,20 +232,154 @@ private fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
}
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
val groupValues = ContentValues().apply {
put(GroupTable.RECIPIENT_ID, recipientId)
put(GroupTable.GROUP_ID, groupId.toString())
put(GroupTable.TITLE, group.name)
put(GroupTable.V2_MASTER_KEY, masterKey.serialize())
put(GroupTable.V2_DECRYPTED_GROUP, placeholderState.encode())
put(GroupTable.V2_REVISION, placeholderState.revision)
put(GroupTable.SHOW_AS_STORY_STATE, group.storySendMode.toGroupShowAsStoryState().code)
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState)
if (restoredId != null) {
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState())
}
writableDatabase.insert(GroupTable.TABLE_NAME, null, groupValues)
return RecipientId.from(recipientId)
}
private fun Group.AccessControl.AccessRequired.toLocal(): AccessControl.AccessRequired {
return when (this) {
Group.AccessControl.AccessRequired.UNKNOWN -> AccessControl.AccessRequired.UNKNOWN
Group.AccessControl.AccessRequired.ANY -> AccessControl.AccessRequired.ANY
Group.AccessControl.AccessRequired.MEMBER -> AccessControl.AccessRequired.MEMBER
Group.AccessControl.AccessRequired.ADMINISTRATOR -> AccessControl.AccessRequired.ADMINISTRATOR
Group.AccessControl.AccessRequired.UNSATISFIABLE -> AccessControl.AccessRequired.UNSATISFIABLE
}
}
private fun Group.AccessControl.toLocal(): AccessControl {
return AccessControl(members = this.members.toLocal(), attributes = this.attributes.toLocal(), addFromInviteLink = this.addFromInviteLink.toLocal())
}
private fun Group.Member.Role.toLocal(): Member.Role {
return when (this) {
Group.Member.Role.UNKNOWN -> Member.Role.UNKNOWN
Group.Member.Role.DEFAULT -> Member.Role.DEFAULT
Group.Member.Role.ADMINISTRATOR -> Member.Role.ADMINISTRATOR
}
}
private fun AccessControl.AccessRequired.toSnapshot(): Group.AccessControl.AccessRequired {
return when (this) {
AccessControl.AccessRequired.UNKNOWN -> Group.AccessControl.AccessRequired.UNKNOWN
AccessControl.AccessRequired.ANY -> Group.AccessControl.AccessRequired.ANY
AccessControl.AccessRequired.MEMBER -> Group.AccessControl.AccessRequired.MEMBER
AccessControl.AccessRequired.ADMINISTRATOR -> Group.AccessControl.AccessRequired.ADMINISTRATOR
AccessControl.AccessRequired.UNSATISFIABLE -> Group.AccessControl.AccessRequired.UNSATISFIABLE
}
}
private fun AccessControl.toSnapshot(): Group.AccessControl {
return Group.AccessControl(members = members.toSnapshot(), attributes = attributes.toSnapshot(), addFromInviteLink = addFromInviteLink.toSnapshot())
}
private fun Member.Role.toSnapshot(): Group.Member.Role {
return when (this) {
Member.Role.UNKNOWN -> Group.Member.Role.UNKNOWN
Member.Role.DEFAULT -> Group.Member.Role.DEFAULT
Member.Role.ADMINISTRATOR -> Group.Member.Role.ADMINISTRATOR
}
}
private fun DecryptedGroup.toSnapshot(): Group.GroupSnapshot? {
if (revision == GroupsV2StateProcessor.RESTORE_PLACEHOLDER_REVISION || revision == GroupsV2StateProcessor.PLACEHOLDER_REVISION) {
return null
}
return Group.GroupSnapshot(
title = Group.GroupAttributeBlob(title = title),
avatarUrl = avatar,
disappearingMessagesTimer = Group.GroupAttributeBlob(disappearingMessagesDuration = disappearingMessagesTimer?.duration ?: 0),
accessControl = accessControl?.toSnapshot(),
version = revision,
members = members.map { it.toSnapshot() },
membersPendingProfileKey = pendingMembers.map { it.toSnapshot() },
membersPendingAdminApproval = requestingMembers.map { it.toSnapshot() },
inviteLinkPassword = inviteLinkPassword,
description = Group.GroupAttributeBlob(descriptionText = description),
announcements_only = isAnnouncementGroup == EnabledState.ENABLED,
members_banned = bannedMembers.map { it.toSnapshot() }
)
}
private fun Group.Member.toLocal(): DecryptedMember {
return DecryptedMember(aciBytes = userId, role = role.toLocal(), profileKey = profileKey, joinedAtRevision = joinedAtVersion)
}
private fun DecryptedMember.toSnapshot(): Group.Member {
return Group.Member(userId = aciBytes, role = role.toSnapshot(), profileKey = profileKey, joinedAtVersion = joinedAtRevision)
}
private fun Group.MemberPendingProfileKey.toLocal(operations: GroupsV2Operations.GroupOperations): DecryptedPendingMember {
return DecryptedPendingMember(
serviceIdBytes = member!!.userId,
role = member.role.toLocal(),
addedByAci = addedByUserId,
timestamp = timestamp,
serviceIdCipherText = operations.encryptServiceId(ServiceId.Companion.parseOrNull(member.userId))
)
}
private fun DecryptedPendingMember.toSnapshot(): Group.MemberPendingProfileKey {
return Group.MemberPendingProfileKey(
member = Group.Member(
userId = serviceIdBytes,
role = role.toSnapshot()
),
addedByUserId = addedByAci,
timestamp = timestamp
)
}
private fun Group.MemberPendingAdminApproval.toLocal(): DecryptedRequestingMember {
return DecryptedRequestingMember(
aciBytes = userId,
profileKey = profileKey,
timestamp = timestamp
)
}
private fun DecryptedRequestingMember.toSnapshot(): Group.MemberPendingAdminApproval {
return Group.MemberPendingAdminApproval(
userId = aciBytes,
profileKey = profileKey,
timestamp = timestamp
)
}
private fun Group.MemberBanned.toLocal(): DecryptedBannedMember {
return DecryptedBannedMember(
serviceIdBytes = userId,
timestamp = timestamp
)
}
private fun DecryptedBannedMember.toSnapshot(): Group.MemberBanned {
return Group.MemberBanned(
userId = serviceIdBytes,
timestamp = timestamp
)
}
private fun Group.GroupSnapshot.toDecryptedGroup(operations: GroupsV2Operations.GroupOperations): DecryptedGroup {
return DecryptedGroup(
title = title?.title ?: "",
avatar = avatarUrl,
disappearingMessagesTimer = DecryptedTimer(duration = disappearingMessagesTimer?.disappearingMessagesDuration ?: 0),
accessControl = accessControl?.toLocal(),
revision = version,
members = members.map { member -> member.toLocal() },
pendingMembers = membersPendingProfileKey.map { pending -> pending.toLocal(operations) },
requestingMembers = membersPendingAdminApproval.map { requesting -> requesting.toLocal() },
inviteLinkPassword = inviteLinkPassword,
description = description?.descriptionText ?: "",
isAnnouncementGroup = if (announcements_only) EnabledState.ENABLED else EnabledState.DISABLED,
bannedMembers = members_banned.map { it.toLocal() }
)
}
private fun Contact.toLocalExtras(): RecipientExtras {
return RecipientExtras(
hideStory = this.hideStory
@@ -331,6 +461,8 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient
val extras = RecipientTableCursorUtil.getExtras(cursor)
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
val decryptedGroup: DecryptedGroup = DecryptedGroup.ADAPTER.decode(cursor.requireBlob(GroupTable.V2_DECRYPTED_GROUP)!!)
return BackupRecipient(
id = cursor.requireLong(RecipientTable.ID),
group = BackupGroup(
@@ -338,7 +470,7 @@ class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
hideStory = extras?.hideStory() ?: false,
storySendMode = showAsStoryState.toGroupStorySendMode(),
name = cursor.requireString(GroupTable.TITLE) ?: ""
snapshot = decryptedGroup.toSnapshot()
)
)
}

View File

@@ -7,29 +7,28 @@ package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.database.getCallsForBackup
import org.thoughtcrime.securesms.backup.v2.database.getAdhocCallsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreCallLogFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.AdHocCall
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
typealias BackupCall = org.thoughtcrime.securesms.backup.v2.proto.Call
object AdHocCallBackupProcessor {
object CallLogBackupProcessor {
val TAG = Log.tag(CallLogBackupProcessor::class.java)
val TAG = Log.tag(AdHocCallBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
SignalDatabase.calls.getCallsForBackup().use { reader ->
SignalDatabase.calls.getAdhocCallsForBackup().use { reader ->
for (callLog in reader) {
if (callLog != null) {
emitter.emit(Frame(call = callLog))
emitter.emit(Frame(adHocCall = callLog))
}
}
}
}
fun import(call: BackupCall, backupState: BackupState) {
fun import(call: AdHocCall, backupState: BackupState) {
SignalDatabase.calls.restoreCallLogFromBackup(call, backupState)
}
}

View File

@@ -19,7 +19,7 @@ object ChatItemBackupProcessor {
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime).use { chatItems ->
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
for (chatItem in chatItems) {
if (exportState.threadIds.contains(chatItem.chatId)) {
emitter.emit(Frame(chatItem = chatItem))

View File

@@ -10,9 +10,13 @@ import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.ExportState
import org.thoughtcrime.securesms.backup.v2.database.BackupRecipient
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
import org.thoughtcrime.securesms.backup.v2.database.getCallLinksForBackup
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreContactFromBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreGroupFromBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreReleaseNotes
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.ReleaseNotes
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
@@ -60,10 +64,26 @@ object RecipientBackupProcessor {
state.recipientIds.add(it.id)
emitter.emit(Frame(recipient = it))
}
SignalDatabase.callLinks.getCallLinksForBackup().forEach {
state.recipientIds.add(it.id)
emitter.emit(Frame(recipient = it))
}
}
fun import(recipient: BackupRecipient, backupState: BackupState) {
val newId = SignalDatabase.recipients.restoreRecipientFromBackup(recipient, backupState)
val newId = when {
recipient.contact != null -> SignalDatabase.recipients.restoreContactFromBackup(recipient.contact)
recipient.group != null -> SignalDatabase.recipients.restoreGroupFromBackup(recipient.group)
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
recipient.self != null -> Recipient.self().id
recipient.releaseNotes != null -> SignalDatabase.recipients.restoreReleaseNotes()
recipient.callLink != null -> SignalDatabase.callLinks.restoreFromBackup(recipient.callLink)
else -> {
Log.w(TAG, "Unrecognized recipient type!")
null
}
}
if (newId != null) {
backupState.backupToLocalRecipientId[recipient.id] = newId
}

View File

@@ -1,75 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.libsignal.protocol.kdf.HKDF
import java.io.FilterOutputStream
import java.io.OutputStream
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class BackupEncryptedOutputStream(key: ByteArray, backupId: ByteArray, wrapped: OutputStream) : FilterOutputStream(wrapped) {
val cipher: Cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
val mac: Mac = Mac.getInstance("HmacSHA256")
var finalMac: ByteArray? = null
init {
if (key.size != 32) {
throw IllegalArgumentException("Key must be 32 bytes!")
}
if (backupId.size != 16) {
throw IllegalArgumentException("BackupId must be 32 bytes!")
}
val extendedKey = HKDF.deriveSecrets(key, backupId, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
val macKey = extendedKey.copyOfRange(0, 32)
val cipherKey = extendedKey.copyOfRange(32, 64)
val iv = extendedKey.copyOfRange(64, 80)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(iv))
mac.init(SecretKeySpec(macKey, "HmacSHA256"))
}
override fun write(b: Int) {
throw UnsupportedOperationException()
}
override fun write(data: ByteArray) {
write(data, 0, data.size)
}
override fun write(data: ByteArray, off: Int, len: Int) {
cipher.update(data, off, len)?.let { ciphertext ->
mac.update(ciphertext)
super.write(ciphertext)
}
}
override fun flush() {
cipher.doFinal()?.let { ciphertext ->
mac.update(ciphertext)
super.write(ciphertext)
}
finalMac = mac.doFinal()
super.flush()
}
override fun close() {
flush()
super.close()
}
fun getMac(): ByteArray {
return finalMac ?: throw IllegalStateException("Mac not yet available! You must call flush() before asking for the mac.")
}
}

View File

@@ -41,18 +41,21 @@ class EncryptedBackupReader(
val stream: InputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
}
val keyMaterial = key.deriveBackupSecrets(aci)
validateMac(keyMaterial.macKey, streamLength, dataStream())
val inputStream = dataStream()
val iv = inputStream.readNBytesOrThrow(16)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
}
stream = GZIPInputStream(
CipherInputStream(
TruncatingInputStream(
wrapped = dataStream(),
wrapped = inputStream,
maxBytes = streamLength - MAC_SIZE
),
cipher

View File

@@ -9,11 +9,11 @@ import org.signal.core.util.stream.MacOutputStream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.IOException
import java.io.OutputStream
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.CipherOutputStream
import javax.crypto.Mac
@@ -33,28 +33,29 @@ class EncryptedBackupWriter(
private val append: (ByteArray) -> Unit
) : BackupExportWriter {
private val mainStream: GZIPOutputStream
private val mainStream: PaddedGzipOutputStream
private val macStream: MacOutputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val keyMaterial = key.deriveBackupSecrets(aci)
val iv: ByteArray = Util.getSecretBytes(16)
outputStream.write(iv)
outputStream.flush()
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
}
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
update(iv)
}
macStream = MacOutputStream(outputStream, mac)
val cipherStream = CipherOutputStream(macStream, cipher)
mainStream = GZIPOutputStream(
CipherOutputStream(
macStream,
cipher
)
)
mainStream = PaddedGzipOutputStream(cipherStream)
}
override fun write(header: BackupInfo) {

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.FilterOutputStream
import java.io.OutputStream
import java.util.zip.GZIPOutputStream
/**
* GZIPs the content of the provided [outputStream], but also adds padding to the end of the stream using the same algorithm as [PaddingInputStream].
* We do this to fit files into a smaller number of size buckets to avoid fingerprinting. And it turns out that bolting on zeros to the end of a GZIP stream is
* fine, because GZIP is smart enough to ignore it. This means readers of this data don't have to do anything special.
*/
class PaddedGzipOutputStream private constructor(private val outputStream: SizeObservingOutputStream) : GZIPOutputStream(outputStream) {
constructor(outputStream: OutputStream) : this(SizeObservingOutputStream(outputStream))
override fun finish() {
super.finish()
val totalLength = outputStream.size
val paddedSize: Long = PaddingInputStream.getPaddedSize(totalLength)
val paddingToAdd: Int = (paddedSize - totalLength).toInt()
outputStream.write(ByteArray(paddingToAdd))
}
/**
* We need to know the size of the *compressed* stream to know how much padding to add at the end.
*/
private class SizeObservingOutputStream(val wrapped: OutputStream) : FilterOutputStream(wrapped) {
var size: Long = 0L
private set
override fun write(b: Int) {
wrapped.write(b)
size++
}
override fun write(b: ByteArray) {
wrapped.write(b)
size += b.size
}
override fun write(b: ByteArray, off: Int, len: Int) {
wrapped.write(b, off, len)
size += len
}
}
}

View File

@@ -0,0 +1,289 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import android.os.Parcelable
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import kotlinx.parcelize.Parcelize
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Icons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Notifies the user of an issue with their backup.
*/
class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_ALERT = "alert"
fun create(backupAlert: BackupAlert): BackupAlertBottomSheet {
return BackupAlertBottomSheet().apply {
arguments = bundleOf(ARG_ALERT to backupAlert)
}
}
}
private val backupAlert: BackupAlert by lazy(LazyThreadSafetyMode.NONE) {
BundleCompat.getParcelable(requireArguments(), ARG_ALERT, BackupAlert::class.java)!!
}
@Composable
override fun SheetContent() {
BackupAlertSheetContent(
backupAlert = backupAlert,
onPrimaryActionClick = this::performPrimaryAction,
onSecondaryActionClick = this::performSecondaryAction
)
}
@Stable
private fun performPrimaryAction() {
when (backupAlert) {
BackupAlert.GENERIC -> {
// TODO [message-backups] -- Back up now
}
BackupAlert.PAYMENT_PROCESSING -> {
// TODO [message-backups] -- Silence
}
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
// TODO [message-backups] -- Download media now
}
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] -- Download media now
}
}
dismissAllowingStateLoss()
}
@Stable
private fun performSecondaryAction() {
when (backupAlert) {
BackupAlert.GENERIC -> {
// TODO [message-backups] - Dismiss and notify later
}
BackupAlert.PAYMENT_PROCESSING -> error("PAYMENT_PROCESSING state does not support a secondary action.")
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
// TODO [message-backups] - Silence and remind on last day
}
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] - Silence forever
}
}
dismissAllowingStateLoss()
}
}
@Composable
private fun BackupAlertSheetContent(
backupAlert: BackupAlert,
onPrimaryActionClick: () -> Unit,
onSecondaryActionClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.size(26.dp))
val iconColors = rememberBackupsIconColors(backupAlert = backupAlert)
Icons.BrushedForeground(
painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [message-backups] final asset
contentDescription = null,
foregroundBrush = iconColors.foreground,
modifier = Modifier
.size(88.dp)
.background(color = iconColors.background, shape = CircleShape)
.padding(20.dp)
)
Text(
text = stringResource(id = rememberTitleResource(backupAlert = backupAlert)),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(top = 16.dp, bottom = 6.dp)
)
when (backupAlert) {
BackupAlert.GENERIC -> GenericBody()
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody()
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody()
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> MediaWillBeDeletedTodayBody()
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp
Buttons.LargeTonal(
onClick = onPrimaryActionClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 60.dp, bottom = padBottom)
) {
Text(text = stringResource(id = rememberPrimaryActionResource(backupAlert = backupAlert)))
}
if (secondaryActionResource > 0) {
TextButton(onClick = onSecondaryActionClick, modifier = Modifier.padding(bottom = 32.dp)) {
Text(text = stringResource(id = secondaryActionResource))
}
}
}
}
@Composable
private fun GenericBody() {
Text(text = "TODO")
}
@Composable
private fun PaymentProcessingBody() {
Text(text = "TODO")
}
@Composable
private fun MediaBackupsAreOffBody() {
Text(text = "TODO")
}
@Composable
private fun MediaWillBeDeletedTodayBody() {
Text(text = "TODO")
}
@Composable
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC, BackupAlert.PAYMENT_PROCESSING -> BackupsIconColors.Warning
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> BackupsIconColors.Error
}
}
}
@Composable
@StringRes
private fun rememberTitleResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
BackupAlert.PAYMENT_PROCESSING -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.default_error_msg // TODO [message-backups] -- Finalized copy
}
}
}
@Composable
private fun rememberPrimaryActionResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC -> android.R.string.ok // TODO [message-backups] -- Finalized copy
BackupAlert.PAYMENT_PROCESSING -> android.R.string.ok // TODO [message-backups] -- Finalized copy
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.ok // TODO [message-backups] -- Finalized copy
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.ok // TODO [message-backups] -- Finalized copy
}
}
}
@Composable
private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
BackupAlert.PAYMENT_PROCESSING -> -1
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
}
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewGeneric() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.GENERIC,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewPayment() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.PAYMENT_PROCESSING,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewMedia() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.MEDIA_BACKUPS_ARE_OFF,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewDelete() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.MEDIA_WILL_BE_DELETED_TODAY,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
}
}
@Parcelize
enum class BackupAlert : Parcelable {
GENERIC,
PAYMENT_PROCESSING,
MEDIA_BACKUPS_ARE_OFF,
MEDIA_WILL_BE_DELETED_TODAY
}

View File

@@ -0,0 +1,45 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
sealed interface BackupsIconColors {
@get:Composable
val foreground: Brush
@get:Composable
val background: Color
object Normal : BackupsIconColors {
override val foreground: Brush
@Composable get() = remember {
Brush.linearGradient(
colors = listOf(Color(0xFF316ED0), Color(0xFF558BE2)),
start = Offset(x = 0f, y = Float.POSITIVE_INFINITY),
end = Offset(x = Float.POSITIVE_INFINITY, y = 0f)
)
}
override val background: Color @Composable get() = MaterialTheme.colorScheme.primaryContainer
}
object Warning : BackupsIconColors {
override val foreground: Brush @Composable get() = SolidColor(Color(0xFFC86600))
override val background: Color @Composable get() = Color(0xFFF9E4B6)
}
object Error : BackupsIconColors {
override val foreground: Brush @Composable get() = SolidColor(MaterialTheme.colorScheme.error)
override val background: Color @Composable get() = Color(0xFFFFD9D9)
}
}

View File

@@ -32,8 +32,8 @@ import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.devicetransfer.moreoptions.MoreTransferOrRestoreOptionsMode
import org.thoughtcrime.securesms.util.navigation.safeNavigate

View File

@@ -0,0 +1,217 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.status
import android.content.res.Configuration
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Buttons
import org.signal.core.ui.Icons
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import kotlin.math.max
import kotlin.math.min
private const val NONE = -1
/**
* Displays a "heads up" widget containing information about the current
* status of the user's backup.
*/
@Composable
fun BackupStatus(
data: BackupStatusData,
onActionClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.border(1.dp, color = MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), shape = RoundedCornerShape(12.dp))
.fillMaxWidth()
.padding(14.dp)
) {
val foreground: Brush = data.iconColors.foreground
Icons.BrushedForeground(
painter = painterResource(id = data.iconRes),
contentDescription = null,
foregroundBrush = foreground,
modifier = Modifier
.background(color = data.iconColors.background, shape = CircleShape)
.padding(8.dp)
)
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f)
) {
Text(
text = stringResource(id = data.titleRes),
style = MaterialTheme.typography.bodyMedium
)
if (data.progress >= 0f) {
LinearProgressIndicator(
progress = data.progress,
strokeCap = StrokeCap.Round,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
)
}
if (data.statusRes != NONE) {
Text(
text = stringResource(id = data.statusRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (data.actionRes != NONE) {
Buttons.Small(
onClick = onActionClick,
modifier = Modifier.padding(start = 8.dp)
) {
Text(text = stringResource(id = data.actionRes))
}
}
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BackupStatusPreview() {
Previews.Preview {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
BackupStatus(
data = BackupStatusData.CouldNotCompleteBackup
)
BackupStatus(
data = BackupStatusData.NotEnoughFreeSpace
)
BackupStatus(
data = BackupStatusData.RestoringMedia(50, 100)
)
}
}
}
/**
* Sealed interface describing status data to display in BackupStatus widget.
*
* TODO [message-requests] - Finalize assets and text
*/
sealed interface BackupStatusData {
@get:DrawableRes
val iconRes: Int
@get:StringRes
val titleRes: Int
val iconColors: BackupsIconColors
@get:StringRes
val actionRes: Int get() = NONE
@get:StringRes
val statusRes: Int get() = NONE
val progress: Float get() = NONE.toFloat()
/**
* Generic failure
*/
object CouldNotCompleteBackup : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val titleRes: Int = R.string.default_error_msg
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
}
/**
* User does not have enough space on their device to complete backup restoration
*/
object NotEnoughFreeSpace : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val titleRes: Int = R.string.default_error_msg
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
override val actionRes: Int = R.string.registration_activity__skip
}
/**
* Restoring media, finished, and paused states.
*/
data class RestoringMedia(
val bytesDownloaded: Long,
val bytesTotal: Long,
val status: Status = Status.NONE
) : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val iconColors: BackupsIconColors = BackupsIconColors.Normal
override val titleRes: Int = when (status) {
Status.NONE -> R.string.default_error_msg
Status.LOW_BATTERY -> R.string.default_error_msg
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
Status.FINISHED -> R.string.default_error_msg
}
override val statusRes: Int = when (status) {
Status.NONE -> R.string.default_error_msg
Status.LOW_BATTERY -> R.string.default_error_msg
Status.WAITING_FOR_INTERNET -> R.string.default_error_msg
Status.WAITING_FOR_WIFI -> R.string.default_error_msg
Status.FINISHED -> R.string.default_error_msg
}
override val progress: Float = if (bytesTotal > 0) {
min(1f, max(0f, bytesDownloaded.toFloat() / bytesTotal))
} else {
0f
}
}
/**
* Describes the status of an in-progress media download session.
*/
enum class Status {
NONE,
LOW_BATTERY,
WAITING_FOR_INTERNET,
WAITING_FOR_WIFI,
FINISHED
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.view.LayoutInflater
import android.view.ViewGroup
@@ -30,23 +30,20 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.updateLayoutParams
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.models.GooglePayButton
import org.thoughtcrime.securesms.databinding.PaypalButtonBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MessageBackupsCheckoutSheet(
messageBackupsType: MessageBackupsType,
messageBackupTier: MessageBackupTier,
availablePaymentGateways: List<GatewayResponse.Gateway>,
onDismissRequest: () -> Unit,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
@@ -57,7 +54,7 @@ fun MessageBackupsCheckoutSheet(
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = messageBackupsType,
messageBackupTier = messageBackupTier,
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = onPaymentGatewaySelected
)
@@ -66,13 +63,16 @@ fun MessageBackupsCheckoutSheet(
@Composable
private fun SheetContent(
messageBackupsType: MessageBackupsType,
messageBackupTier: MessageBackupTier,
availablePaymentGateways: List<GatewayResponse.Gateway>,
onPaymentGatewaySelected: (GatewayResponse.Gateway) -> Unit
) {
val resources = LocalContext.current.resources
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
val backupTypeDetails = remember(messageBackupTier) {
getTierDetails(messageBackupTier)
}
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
@@ -88,7 +88,7 @@ private fun SheetContent(
)
MessageBackupsTypeBlock(
messageBackupsType = messageBackupsType,
messageBackupsType = backupTypeDetails,
isSelected = false,
onSelected = {},
enabled = false,
@@ -221,29 +221,6 @@ private fun CreditOrDebitCardButton(
@Preview
@Composable
private fun MessageBackupsCheckoutSheetPreview() {
val paidTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
val availablePaymentGateways = GatewayResponse.Gateway.values().toList()
Previews.Preview {
@@ -252,7 +229,7 @@ private fun MessageBackupsCheckoutSheetPreview() {
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupsType = paidTier,
messageBackupTier = MessageBackupTier.PAID,
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.background

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.os.Bundle
import androidx.activity.compose.setContent
@@ -32,6 +32,10 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
fun MessageBackupsScreen.next() {
val nextScreen = viewModel.goToNextScreen(this)
if (nextScreen == MessageBackupsScreen.COMPLETED) {
finishAfterTransition()
return
}
if (nextScreen != this) {
navController.navigate(nextScreen.name)
}
@@ -53,7 +57,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
NavHost(
navController = navController,
startDestination = MessageBackupsScreen.EDUCATION.name,
startDestination = if (state.currentMessageBackupTier == null) MessageBackupsScreen.EDUCATION.name else MessageBackupsScreen.TYPE_SELECTION.name,
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
@@ -88,9 +92,9 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
selectedBackupsType = state.selectedMessageBackupsType,
availableBackupsTypes = state.availableBackupsTypes,
onMessageBackupsTypeSelected = viewModel::onMessageBackupsTypeUpdated,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTiers = state.availableBackupTiers,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = navController::popOrFinish,
onReadMoreClicked = {},
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
@@ -99,7 +103,7 @@ class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.selectedMessageBackupsType!!,
messageBackupTier = state.selectedMessageBackupTier!!,
availablePaymentGateways = state.availablePaymentGateways,
onDismissRequest = navController::popOrFinish,
onPaymentGatewaySelected = {

View File

@@ -3,6 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
class MessageBackupsFlowRepository

View File

@@ -3,15 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class MessageBackupsFlowState(
val selectedMessageBackupsType: MessageBackupsType? = null,
val availableBackupsTypes: List<MessageBackupsType> = emptyList(),
val selectedMessageBackupTier: MessageBackupTier? = null,
val currentMessageBackupTier: MessageBackupTier? = null,
val availableBackupTiers: List<MessageBackupTier> = emptyList(),
val selectedPaymentGateway: GatewayResponse.Gateway? = null,
val availablePaymentGateways: List<GatewayResponse.Gateway> = emptyList(),
val pin: String = "",

View File

@@ -3,16 +3,31 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.text.TextUtils
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.api.kbs.PinHashUtil.verifyLocalPinHash
class MessageBackupsFlowViewModel : ViewModel() {
private val internalState = mutableStateOf(MessageBackupsFlowState())
private val internalState = mutableStateOf(
MessageBackupsFlowState(
availableBackupTiers = if (!FeatureFlags.messageBackups()) {
emptyList()
} else {
listOf(MessageBackupTier.FREE, MessageBackupTier.PAID)
},
selectedMessageBackupTier = SignalStore.backup().backupTier
)
)
val state: State<MessageBackupsFlowState> = internalState
@@ -40,16 +55,27 @@ class MessageBackupsFlowViewModel : ViewModel() {
internalState.value = state.value.copy(selectedPaymentGateway = gateway)
}
fun onMessageBackupsTypeUpdated(messageBackupsType: MessageBackupsType) {
internalState.value = state.value.copy(selectedMessageBackupsType = messageBackupsType)
fun onMessageBackupTierUpdated(messageBackupTier: MessageBackupTier) {
internalState.value = state.value.copy(selectedMessageBackupTier = messageBackupTier)
}
private fun validatePinAndUpdateState(): MessageBackupsScreen {
val pinHash = SignalStore.svr().localPinHash
val pin = state.value.pin
if (pinHash == null || TextUtils.isEmpty(pin) || pin.length < SvrConstants.MINIMUM_PIN_LENGTH) return MessageBackupsScreen.PIN_CONFIRMATION
if (!verifyLocalPinHash(pinHash, pin)) {
return MessageBackupsScreen.PIN_CONFIRMATION
}
return MessageBackupsScreen.TYPE_SELECTION
}
private fun validateTypeAndUpdateState(): MessageBackupsScreen {
return MessageBackupsScreen.CHECKOUT_SHEET
SignalStore.backup().canReadWriteToArchiveCdn = state.value.selectedMessageBackupTier == MessageBackupTier.PAID
SignalStore.backup().areBackupsEnabled = true
return MessageBackupsScreen.COMPLETED
// return MessageBackupsScreen.CHECKOUT_SHEET TODO [message-backups] Switch back to payment flow
}
private fun validateGatewayAndUpdateState(): MessageBackupsScreen {

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,6 +16,7 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
@@ -32,6 +33,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -52,93 +54,95 @@ fun MessageBackupsPinConfirmationScreen(
onNextClick: () -> Unit
) {
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
LazyColumn(
Surface {
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.fillMaxSize()
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
item {
Text(
text = "Enter your PIN", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
}
item {
Text(
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
// TODO [message-backups] Confirm default focus state
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
item {
Text(
text = "Enter your PIN", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 40.dp)
)
}
TextField(
value = pin,
onValueChange = onPinChanged,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(
onDone = { onNextClick() }
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
modifier = Modifier
.padding(top = 72.dp)
.fillMaxWidth()
.focusRequester(focusRequester)
)
item {
Text(
text = "Enter your Signal PIN to enable backups", // TODO [message-backups] Finalized copy
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
item {
// TODO [message-backups] Confirm default focus state
val keyboardType = remember(pinKeyboardType) {
when (pinKeyboardType) {
PinKeyboardType.NUMERIC -> KeyboardType.NumberPassword
PinKeyboardType.ALPHA_NUMERIC -> KeyboardType.Password
}
}
TextField(
value = pin,
onValueChange = onPinChanged,
textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
keyboardActions = KeyboardActions(
onDone = { onNextClick() }
),
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType
),
modifier = Modifier
.padding(top = 72.dp)
.fillMaxWidth()
.focusRequester(focusRequester),
visualTransformation = PasswordVisualTransformation()
)
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
) {
PinKeyboardTypeToggle(
pinKeyboardType = pinKeyboardType,
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
)
}
}
}
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 48.dp)
Box(
contentAlignment = Alignment.BottomEnd,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick
) {
PinKeyboardTypeToggle(
pinKeyboardType = pinKeyboardType,
onPinKeyboardTypeSelected = onPinKeyboardTypeSelected
Text(
text = "Next" // TODO [message-backups] Finalized copy
)
}
}
}
Box(
contentAlignment = Alignment.BottomEnd,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Buttons.LargeTonal(
onClick = onNextClick
) {
Text(
text = "Next" // TODO [message-backups] Finalized copy
)
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
@@ -96,22 +96,22 @@ fun MessageBackupsPinEducationScreen(
}
Buttons.LargePrimary(
onClick = onGeneratePinClick,
onClick = onUseCurrentPinClick,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
)
}
TextButton(
onClick = onUseCurrentPinClick,
onClick = onGeneratePinClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Text(
text = "Use current Signal PIN" // TODO [message-backups] Finalized copy
text = "Generate a new $recommendedPinSize-digit PIN" // TODO [message-backups] Finalized copy
)
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
enum class MessageBackupsScreen {
EDUCATION,

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.content.Context
import android.content.Intent
@@ -26,6 +26,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -80,6 +81,15 @@ class MessageBackupsTestRestoreActivity : BaseActivity() {
.fillMaxSize()
.padding(16.dp)
) {
Buttons.LargePrimary(
onClick = this@MessageBackupsTestRestoreActivity::restoreFromServer,
enabled = !state.importState.inProgress
) {
Text("Restore")
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
@@ -120,9 +130,20 @@ class MessageBackupsTestRestoreActivity : BaseActivity() {
}
}
}
if (state.importState == MessageBackupsTestRestoreViewModel.ImportState.RESTORED) {
SideEffect {
RegistrationUtil.maybeMarkRegistrationComplete()
ApplicationDependencies.getJobManager().add(ProfileUploadJob())
startActivity(MainActivity.clearTop(this))
}
}
}
}
private fun restoreFromServer() {
viewModel.restore()
}
private fun continueRegistration() {
if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
val main = MainActivity.clearTop(this)

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
@@ -17,8 +17,13 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
import org.thoughtcrime.securesms.recipients.Recipient
import java.io.InputStream
import kotlin.time.Duration.Companion.seconds
class MessageBackupsTestRestoreViewModel : ViewModel() {
val disposables = CompositeDisposable()
@@ -40,6 +45,23 @@ class MessageBackupsTestRestoreViewModel : ViewModel() {
}
}
fun restore() {
_state.value = _state.value.copy(importState = ImportState.IN_PROGRESS)
disposables += Single.fromCallable {
ApplicationDependencies
.getJobManager()
.startChain(BackupRestoreJob())
.then(SyncArchivedMediaJob())
.then(BackupRestoreMediaJob())
.enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(importState = ImportState.RESTORED)
}
}
fun onPlaintextToggled() {
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
}
@@ -54,6 +76,6 @@ class MessageBackupsTestRestoreViewModel : ViewModel() {
)
enum class ImportState(val inProgress: Boolean = false) {
NONE, IN_PROGRESS(true)
NONE, IN_PROGRESS(true), RESTORED
}
}

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -18,6 +18,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
/**
* Represents a "Feature" included for a specify tier of message backups
@@ -53,3 +56,16 @@ fun MessageBackupsTypeFeatureRow(
)
}
}
@SignalPreview
@Composable
private fun MessageBackupsTypeFeatureRowPreview() {
Previews.Preview {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_edit_24,
label = "Content Label"
)
)
}
}

View File

@@ -2,7 +2,7 @@
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -21,6 +21,7 @@ import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -38,16 +39,17 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import java.math.BigDecimal
import java.util.Currency
@@ -58,9 +60,9 @@ import java.util.Currency
@OptIn(ExperimentalTextApi::class)
@Composable
fun MessageBackupsTypeSelectionScreen(
selectedBackupsType: MessageBackupsType?,
availableBackupsTypes: List<MessageBackupsType>,
onMessageBackupsTypeSelected: (MessageBackupsType) -> Unit,
selectedBackupTier: MessageBackupTier?,
availableBackupTiers: List<MessageBackupTier>,
onMessageBackupsTierSelected: (MessageBackupTier) -> Unit,
onNavigationClick: () -> Unit,
onReadMoreClicked: () -> Unit,
onNextClicked: () -> Unit
@@ -127,13 +129,16 @@ fun MessageBackupsTypeSelectionScreen(
}
itemsIndexed(
availableBackupsTypes,
{ _, item -> item.title }
availableBackupTiers,
{ _, item -> item }
) { index, item ->
val type = remember(item) {
getTierDetails(item)
}
MessageBackupsTypeBlock(
messageBackupsType = item,
isSelected = item == selectedBackupsType,
onSelected = { onMessageBackupsTypeSelected(item) },
messageBackupsType = type,
isSelected = item == selectedBackupTier,
onSelected = { onMessageBackupsTierSelected(item) },
modifier = Modifier.padding(top = if (index == 0) 20.dp else 18.dp)
)
}
@@ -153,54 +158,16 @@ fun MessageBackupsTypeSelectionScreen(
}
}
@Preview
@SignalPreview
@Composable
private fun MessageBackupsTypeSelectionScreenPreview() {
val freeTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
title = "Text + 30 days of media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media"
)
)
)
val paidTier = MessageBackupsType(
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
var selectedBackupsType by remember { mutableStateOf(freeTier) }
var selectedBackupsType by remember { mutableStateOf(MessageBackupTier.FREE) }
Previews.Preview {
MessageBackupsTypeSelectionScreen(
selectedBackupsType = selectedBackupsType,
availableBackupsTypes = listOf(freeTier, paidTier),
onMessageBackupsTypeSelected = { selectedBackupsType = it },
selectedBackupTier = MessageBackupTier.FREE,
availableBackupTiers = listOf(MessageBackupTier.FREE, MessageBackupTier.PAID),
onMessageBackupsTierSelected = { selectedBackupsType = it },
onNavigationClick = {},
onReadMoreClicked = {},
onNextClicked = {}
@@ -269,8 +236,53 @@ private fun formatCostPerMonth(pricePerMonth: FiatMoney): String {
}
}
@Stable
data class MessageBackupsType(
val tier: MessageBackupTier,
val pricePerMonth: FiatMoney,
val title: String,
val features: ImmutableList<MessageBackupsTypeFeature>
)
fun getTierDetails(tier: MessageBackupTier): MessageBackupsType {
return when (tier) {
MessageBackupTier.FREE -> MessageBackupsType(
tier = MessageBackupTier.FREE,
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
title = "Text + 30 days of media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media"
)
)
)
MessageBackupTier.PAID -> MessageBackupsType(
tier = MessageBackupTier.PAID,
pricePerMonth = FiatMoney(BigDecimal.valueOf(3), Currency.getInstance("USD")),
title = "Text + All your media",
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)"
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!"
)
)
)
}
}

View File

@@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadgeAmounts
import org.thoughtcrime.securesms.components.settings.app.subscription.getGiftBadges
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
import org.whispersystems.signalservice.internal.push.SubscriptionsConfiguration
import java.util.Currency
import java.util.Locale
@@ -28,7 +28,7 @@ class GiftFlowRepository {
.getDonationsConfiguration(Locale.getDefault())
}
.flatMap { it.flattenResult() }
.map { DonationsConfiguration.GIFT_LEVEL to it.getGiftBadges().first() }
.map { SubscriptionsConfiguration.GIFT_LEVEL to it.getGiftBadges().first() }
.subscribeOn(Schedulers.io())
}

View File

@@ -24,10 +24,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ShareCompat
@@ -128,19 +127,19 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
icon = painterResource(id = R.drawable.symbol_forward_24),
onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareViaSignalClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
icon = painterResource(id = R.drawable.symbol_copy_android_24),
onClick = this@CreateCallLinkBottomSheetDialogFragment::onCopyLinkClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_share_android_24),
icon = painterResource(id = R.drawable.symbol_share_android_24),
onClick = this@CreateCallLinkBottomSheetDialogFragment::onShareLinkClicked
)

View File

@@ -10,11 +10,13 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.CallLinkUpdateSendJob
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CreateCallLinkResult
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkManager
import org.whispersystems.signalservice.internal.push.SyncMessage
/**
* Repository for creating new call links. This will delegate to the [SignalCallLinkManager]
@@ -44,6 +46,13 @@ class CreateCallLinkRepository(
)
)
ApplicationDependencies.getJobManager().add(
CallLinkUpdateSendJob(
credentials.roomId,
SyncMessage.CallLinkUpdate.Type.UPDATE
)
)
EnsureCallLinkCreatedResult.Success(
Recipient.resolved(
SignalDatabase.recipients.getByCallLinkRoomId(credentials.roomId).get()

View File

@@ -17,10 +17,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
@@ -287,25 +285,25 @@ private fun CallLinkDetails(
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
icon = painterResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
icon = painterResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
icon = painterResource(id = R.drawable.symbol_link_24),
onClick = callback::onShareClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
icon = painterResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)

View File

@@ -305,7 +305,7 @@ class CallLogAdapter(
val color = ContextCompat.getColor(
context,
if (call.record.event.isMissedCall()) {
if (call.record.isDisplayedAsMissedCallInUi) {
R.color.signal_colorError
} else {
R.color.signal_colorOnSurfaceVariant
@@ -371,11 +371,11 @@ class CallLogAdapter(
private fun getCallStateDrawableRes(call: CallTable.Call): Int {
return when (call.messageType) {
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> R.drawable.symbol_missed_incoming_compact_16
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.drawable.symbol_arrow_downleft_compact_16
MessageTypes.INCOMING_AUDIO_CALL_TYPE, MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) R.drawable.symbol_missed_incoming_compact_16 else R.drawable.symbol_arrow_downleft_compact_16
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.isMissedCall() -> R.drawable.symbol_missed_incoming_compact_16
call.isDisplayedAsMissedCallInUi -> 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
@@ -389,23 +389,19 @@ class CallLogAdapter(
@StringRes
private fun getCallStateStringRes(call: CallTable.Call): Int {
return when (call.messageType) {
MessageTypes.MISSED_VIDEO_CALL_TYPE,
MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__incoming
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__incoming
MessageTypes.MISSED_VIDEO_CALL_TYPE, MessageTypes.MISSED_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.MISSED) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__missed_notification_profile
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.CallLogAdapter__outgoing
MessageTypes.GROUP_CALL_TYPE -> when {
call.type == CallTable.Type.AD_HOC_CALL -> R.string.CallLogAdapter__call_link
call.event == CallTable.Event.MISSED -> R.string.CallLogAdapter__missed
call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE -> R.string.CallLogAdapter__missed_notification_profile
call.isDisplayedAsMissedCallInUi -> R.string.CallLogAdapter__missed
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
call.direction == CallTable.Direction.INCOMING -> R.string.CallLogAdapter__incoming
call.direction == CallTable.Direction.OUTGOING -> R.string.CallLogAdapter__outgoing
else -> throw AssertionError()
}
else -> error("Unexpected type ${call.messageType}")
else -> if (call.isDisplayedAsMissedCallInUi) R.string.CallLogAdapter__missed else R.string.CallLogAdapter__incoming
}
}
}

View File

@@ -122,6 +122,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
initializeSharedElementTransition()
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
viewLifecycleOwner.lifecycle.addObserver(viewModel.callLogPeekHelper)
val callLogAdapter = CallLogAdapter(this)
disposables.bindTo(viewLifecycleOwner)

View File

@@ -67,6 +67,7 @@ class CallLogPagedDataSource(
callLogRows.add(CallLogRow.ClearFilter)
}
repository.onCallTabPageLoaded(callLogRows)
return callLogRows
}
@@ -83,5 +84,6 @@ class CallLogPagedDataSource(
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
fun getCallLinksCount(query: String?, filter: CallLogFilter): Int
fun getCallLinks(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
fun onCallTabPageLoaded(pageData: List<CallLogRow>)
}
}

View File

@@ -0,0 +1,188 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.log
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.ThrottledDebouncer
import org.thoughtcrime.securesms.util.concurrent.SerialExecutor
import java.util.concurrent.Executor
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.seconds
/**
* Peeks calls in the call log as data is loaded in, according to
* an algorithm.
*/
class CallLogPeekHelper : DefaultLifecycleObserver {
companion object {
private val TAG = Log.tag(CallLogPeekHelper::class.java)
private const val PEEK_SIZE = 10
}
private val executor: Executor = SerialExecutor(SignalExecutors.BOUNDED_IO)
private val debouncer = ThrottledDebouncer(30.seconds.inWholeMilliseconds)
private val dataSet = mutableSetOf<PeekEntry>()
private val peekQueue = mutableSetOf<PeekEntry>()
private var isFirstLoad = true
private var isPaused = false
override fun onResume(owner: LifecycleOwner) {
executor.execute {
isPaused = false
performPeeks()
}
}
override fun onPause(owner: LifecycleOwner) {
executor.execute {
isPaused = true
debouncer.clear()
}
}
/**
* Called whenever the underlying datasource has been invalidated.
*/
fun onDataSetInvalidated() {
executor.execute {
debouncer.clear()
dataSet.clear()
peekQueue.clear()
}
}
/**
* Called whenever a new page of data is loaded by the datasource.
*/
fun onPageLoaded(pageData: List<CallLogRow>) {
executor.execute {
handleActiveCallLinks(pageData)
handleActiveGroupCalls(pageData)
handleInactiveGroupCalls(pageData)
handleInactiveCallLinks(pageData)
performPeeks()
}
}
/**
* Adds any and all active call links to our data set and queue
*/
private fun handleActiveCallLinks(pageData: List<CallLogRow>) {
val activeUnusedCallLinks: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.CallLink>()
.filter { it.callLinkPeekInfo?.isActive == true }
.map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) }
val activeCallLinksFromEvents: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
.filter { it.peer.isCallLink && it.callLinkPeekInfo?.isActive == true }
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) }
val activeCallLinks: List<PeekEntry> = activeUnusedCallLinks + activeCallLinksFromEvents
dataSet.addAll(activeCallLinks)
peekQueue.addAll(activeCallLinks)
}
/**
* Adds any and all active group calls to our dataset and queue.
*/
private fun handleActiveGroupCalls(pageData: List<CallLogRow>) {
val activeGroupCalls: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
.filter { it.peer.isGroup && it.groupCallState != CallLogRow.GroupCallState.NONE }
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) }
dataSet.addAll(activeGroupCalls)
peekQueue.addAll(activeGroupCalls)
}
/**
* Removes any and all inactive group calls from our dataset and queue.
*/
private fun handleInactiveGroupCalls(pageData: List<CallLogRow>) {
val inactiveGroupCalls: Set<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
.filter { it.peer.isGroup && it.groupCallState == CallLogRow.GroupCallState.NONE }
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.GROUP_CALL)) }
.toSet()
peekQueue.removeAll(inactiveGroupCalls)
dataSet.removeAll(inactiveGroupCalls)
}
/**
* On first load, adds all inactive call links to our queue. On subsequent calls, removes them from the dataset.
*/
private fun handleInactiveCallLinks(pageData: List<CallLogRow>) {
val inactiveUnusedCallLinks: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.CallLink>()
.filter { it.callLinkPeekInfo?.isActive != true }
.map { PeekEntry(it.recipient.id, PeekEntryIdentifier.CallLink(it.record.roomId, PeekEntryType.CALL_LINK)) }
val inactiveCallLinksFromEvents: List<PeekEntry> = pageData.filterIsInstance<CallLogRow.Call>()
.filter { it.callLinkPeekInfo?.isActive != true }
.filter { it.record.timestamp <= 10.days.inWholeMilliseconds }
.map { PeekEntry(it.peer.id, PeekEntryIdentifier.Call(it.record.callId, PeekEntryType.CALL_LINK)) }
val inactiveCallLinks: Set<PeekEntry> = (inactiveUnusedCallLinks + inactiveCallLinksFromEvents).take(10).toSet()
if (isFirstLoad) {
isFirstLoad = false
peekQueue.addAll(inactiveCallLinks)
} else {
dataSet.removeAll(inactiveCallLinks)
}
}
private fun performPeeks() {
executor.execute {
if (peekQueue.isEmpty() || isPaused) {
return@execute
}
Log.d(TAG, "Peeks in queue. Taking first $PEEK_SIZE.")
val items = peekQueue.take(PEEK_SIZE)
val remaining = peekQueue.drop(PEEK_SIZE)
peekQueue.clear()
peekQueue.addAll(remaining)
items.forEach {
when (it.identifier.peekEntryType) {
PeekEntryType.CALL_LINK -> ApplicationDependencies.getSignalCallManager().peekCallLinkCall(it.recipientId)
PeekEntryType.GROUP_CALL -> ApplicationDependencies.getSignalCallManager().peekGroupCall(it.recipientId)
}
}
Log.d(TAG, "Began peeks for ${items.size} calls.")
peekQueue.addAll(dataSet)
debouncer.publish { performPeeks() }
}
}
private enum class PeekEntryType {
CALL_LINK,
GROUP_CALL
}
private sealed interface PeekEntryIdentifier {
val peekEntryType: PeekEntryType
data class CallLink(private val roomId: CallLinkRoomId, override val peekEntryType: PeekEntryType = PeekEntryType.CALL_LINK) : PeekEntryIdentifier
data class Call(private val callId: Long, override val peekEntryType: PeekEntryType) : PeekEntryIdentifier
}
private data class PeekEntry(
val recipientId: RecipientId,
val identifier: PeekEntryIdentifier
)
}

View File

@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
class CallLogRepository(
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
private val updateCallLinkRepository: UpdateCallLinkRepository = UpdateCallLinkRepository(),
private val callLogPeekHelper: CallLogPeekHelper
) : CallLogPagedDataSource.CallRepository {
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
return SignalDatabase.calls.getCallsCount(query, filter)
@@ -41,6 +42,12 @@ class CallLogRepository(
}
}
override fun onCallTabPageLoaded(pageData: List<CallLogRow>) {
SignalExecutors.BOUNDED_IO.execute {
callLogPeekHelper.onPageLoaded(pageData)
}
}
fun markAllCallEventsRead() {
SignalExecutors.BOUNDED_IO.execute {
val latestCall = SignalDatabase.calls.getLatestCall() ?: return@execute

View File

@@ -24,7 +24,8 @@ import java.util.concurrent.TimeUnit
* ViewModel for call log management.
*/
class CallLogViewModel(
private val callLogRepository: CallLogRepository = CallLogRepository()
val callLogPeekHelper: CallLogPeekHelper = CallLogPeekHelper(),
private val callLogRepository: CallLogRepository = CallLogRepository(callLogPeekHelper = callLogPeekHelper)
) : ViewModel() {
private val callLogStore = RxStore(CallLogState())
@@ -77,6 +78,7 @@ class CallLogViewModel(
}
disposables += callLogRepository.listenForChanges().subscribe {
callLogPeekHelper.onDataSetInvalidated()
controller.onDataInvalidated()
}
@@ -92,6 +94,7 @@ class CallLogViewModel(
.observeOn(Schedulers.computation())
.distinctUntilChanged()
.subscribe {
callLogPeekHelper.onDataSetInvalidated()
controller.onDataInvalidated()
}
}

View File

@@ -53,7 +53,7 @@ class NewCallActivity : ContactSelectionActivity(), ContactSelectionListFragment
when (result) {
is RecipientRepository.LookupResult.Success -> {
val resolved = Recipient.resolved(result.recipientId)
if (resolved.isRegistered && resolved.hasServiceId()) {
if (resolved.isRegistered && resolved.hasServiceId) {
launch(resolved)
}
}

View File

@@ -35,12 +35,6 @@ public class AlertView extends AppCompatImageView {
setVisibility(View.GONE);
}
public void setPendingApproval() {
setVisibility(View.VISIBLE);
setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_colorOnSurfaceVariant));
setContentDescription(getContext().getString(R.string.conversation_item_sent__pending_approval_description));
}
public void setFailed() {
setVisibility(View.VISIBLE);
setColorFilter(ContextCompat.getColor(getContext(), R.color.signal_colorError));

View File

@@ -202,6 +202,7 @@ public final class AudioView extends FrameLayout {
} else if (showControls && audio.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_STARTED) {
controlToggle.displayQuick(progressAndPlay);
seekBar.setEnabled(false);
showPlayButton();
if (circleProgress != null) {
circleProgress.setVisibility(View.VISIBLE);
circleProgress.spin();

View File

@@ -176,7 +176,7 @@ public final class AvatarImageView extends AppCompatImageView {
new ProfileContactPhoto(Recipient.self()))
: new RecipientContactPhoto(recipient);
boolean shouldBlur = recipient.shouldBlurAvatar();
boolean shouldBlur = recipient.getShouldBlurAvatar();
ChatColors chatColors = recipient.getChatColors();
if (!photo.equals(recipientContactPhoto) || shouldBlur != blurred || !Objects.equals(chatColors, this.chatColors)) {

View File

@@ -57,9 +57,9 @@ import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
public class ComposeText extends EmojiEditText {
private static final char EMOJI_STARTER = ':';
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
private static final char EMOJI_STARTER = ':';
private static final int MAX_QUERY_LENGTH = 64;
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
private CharSequence hint;
private MentionRendererDelegate mentionRendererDelegate;
@@ -370,16 +370,16 @@ public class ComposeText extends EmojiEditText {
}
private void doAfterCursorChange(@NonNull Editable text) {
if (enoughToFilter(text, false)) {
performFiltering(text, false);
if (canFilter(text)) {
performFiltering(text);
} else {
clearInlineQuery();
}
}
private void performFiltering(@NonNull Editable text, boolean keywordEmojiSearch) {
private void performFiltering(@NonNull Editable text) {
int end = getSelectionEnd();
QueryStart queryStart = findQueryStart(text, end, keywordEmojiSearch);
QueryStart queryStart = findQueryStart(text, end);
int start = queryStart.index;
String query = text.subSequence(start, end).toString();
@@ -387,7 +387,7 @@ public class ComposeText extends EmojiEditText {
if (queryStart.isMentionQuery) {
inlineQueryChangedListener.onQueryChanged(new InlineQuery.Mention(query));
} else {
inlineQueryChangedListener.onQueryChanged(new InlineQuery.Emoji(query, keywordEmojiSearch));
inlineQueryChangedListener.onQueryChanged(new InlineQuery.Emoji(query));
}
}
}
@@ -398,23 +398,25 @@ public class ComposeText extends EmojiEditText {
}
}
private boolean enoughToFilter(@NonNull Editable text, boolean keywordEmojiSearch) {
private boolean canFilter(@NonNull Editable text) {
int end = getSelectionEnd();
if (end < 0) {
return false;
}
return findQueryStart(text, end, keywordEmojiSearch).index != -1;
QueryStart start = findQueryStart(text, end);
return start.index != -1 && ((end - start.index) <= MAX_QUERY_LENGTH);
}
public void replaceTextWithMention(@NonNull String displayName, @NonNull RecipientId recipientId) {
replaceText(createReplacementToken(displayName, recipientId), false);
replaceText(createReplacementToken(displayName, recipientId));
}
public void replaceText(@NonNull InlineQueryReplacement replacement) {
replaceText(replacement.toCharSequence(getContext()), replacement.isKeywordSearch());
replaceText(replacement.toCharSequence(getContext()));
}
private void replaceText(@NonNull CharSequence replacement, boolean keywordReplacement) {
private void replaceText(@NonNull CharSequence replacement) {
Editable text = getText();
if (text == null) {
return;
@@ -423,7 +425,7 @@ public class ComposeText extends EmojiEditText {
clearComposingText();
int end = getSelectionEnd();
int start = findQueryStart(text, end, keywordReplacement).index - (keywordReplacement ? 0 : 1);
int start = findQueryStart(text, end).index - 1;
text.replace(start, end, "");
text.insert(start, replacement);
@@ -444,17 +446,7 @@ public class ComposeText extends EmojiEditText {
return builder;
}
private QueryStart findQueryStart(@NonNull CharSequence text, int inputCursorPosition, boolean keywordEmojiSearch) {
if (keywordEmojiSearch) {
int start = findQueryStart(text, inputCursorPosition, ' ');
if (start == -1 && inputCursorPosition != 0) {
start = 0;
} else if (start == inputCursorPosition) {
start = -1;
}
return new QueryStart(start, false);
}
private QueryStart findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
QueryStart queryStart = new QueryStart(findQueryStart(text, inputCursorPosition, MENTION_STARTER), true);
if (queryStart.index < 0) {

View File

@@ -315,8 +315,6 @@ public class ConversationItemFooter extends ConstraintLayout {
}
dateView.setText(errorMsg);
} else if (messageRecord.isPendingInsecureSmsFallback()) {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
@@ -410,7 +408,7 @@ public class ConversationItemFooter extends ConstraintLayout {
previousMessageId = newMessageId;
if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) {
if (messageRecord.isFailed() || MessageRecordUtil.isScheduled(messageRecord)) {
deliveryStatusView.setNone();
return;
}

View File

@@ -22,6 +22,7 @@ public class ConversationSearchBottomBar extends ConstraintLayout {
private View searchUp;
private TextView searchPositionText;
private View progressWheel;
private View jumpToDateButton;
private EventListener eventListener;
@@ -42,6 +43,7 @@ public class ConversationSearchBottomBar extends ConstraintLayout {
this.searchDown = findViewById(R.id.conversation_search_down);
this.searchPositionText = findViewById(R.id.conversation_search_position);
this.progressWheel = findViewById(R.id.conversation_search_progress_wheel);
this.jumpToDateButton = findViewById(R.id.conversation_jump_to_date_button);
}
public void setData(int position, int count) {
@@ -65,6 +67,12 @@ public class ConversationSearchBottomBar extends ConstraintLayout {
searchPositionText.setText(R.string.ConversationActivity_no_results);
}
jumpToDateButton.setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onDatePickerSelected();
}
});
setViewEnabled(searchUp, position < (count - 1));
setViewEnabled(searchDown, position > 0);
}
@@ -85,5 +93,6 @@ public class ConversationSearchBottomBar extends ConstraintLayout {
public interface EventListener {
void onSearchMoveUpPressed();
void onSearchMoveDownPressed();
void onDatePickerSelected();
}
}

View File

@@ -40,9 +40,15 @@ public class FromTextView extends SimpleEmojiTextView {
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, @Nullable CharSequence suffix, boolean asThread) {
setText(recipient, fromString, suffix, asThread, false);
}
public void setText(Recipient recipient, @Nullable CharSequence fromString, @Nullable CharSequence suffix, boolean asThread, boolean showSelfAsYou) {
SpannableStringBuilder builder = new SpannableStringBuilder();
if (asThread && recipient.isSelf()) {
if (asThread && recipient.isSelf() && showSelfAsYou) {
builder.append(getContext().getString(R.string.Recipient_you));
} else if (asThread && recipient.isSelf()) {
builder.append(getContext().getString(R.string.note_to_self));
} else {
builder.append(fromString);
@@ -52,7 +58,7 @@ public class FromTextView extends SimpleEmojiTextView {
builder.append(suffix);
}
if (asThread && recipient.showVerified()) {
if (asThread && recipient.getShowVerified()) {
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));

View File

@@ -191,10 +191,10 @@ class ScrollToPositionDelegate private constructor(
if (abs(layoutManager.findFirstVisibleItemPosition() - position) < SCROLL_ANIMATION_THRESHOLD) {
val child: View? = layoutManager.findViewByPosition(position)
if (child == null || !layoutManager.isViewPartiallyVisible(child, true, false)) {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4)
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
} else {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 4)
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
}
}

View File

@@ -59,7 +59,7 @@ class VerificationCodeView @JvmOverloads constructor(context: Context, attrs: At
}
}
interface OnCodeEnteredListener {
fun interface OnCodeEnteredListener {
fun onCodeComplete(code: String)
}

View File

@@ -20,9 +20,4 @@ public class BaseSettingsAdapter extends MappingAdapter {
registerFactory(SingleSelectSetting.Item.class,
new LayoutFactory<>(v -> new SingleSelectSetting.ViewHolder(v, selectionChangedListener), R.layout.single_select_item));
}
public void configureCustomizableSingleSelect(@NonNull CustomizableSingleSelectSetting.CustomizableSingleSelectionListener selectionListener) {
registerFactory(CustomizableSingleSelectSetting.Item.class,
new LayoutFactory<>(v -> new CustomizableSingleSelectSetting.ViewHolder(v, selectionListener), R.layout.customizable_single_select_item));
}
}

View File

@@ -1,93 +0,0 @@
package org.thoughtcrime.securesms.components.settings;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
import java.io.Serializable;
import java.util.Objects;
/**
* A simple settings screen that takes its configuration via {@link Configuration}.
*/
public class BaseSettingsFragment extends Fragment {
private static final String CONFIGURATION_ARGUMENT = "current_selection";
private RecyclerView recycler;
public static @NonNull BaseSettingsFragment create(@NonNull Configuration configuration) {
BaseSettingsFragment fragment = new BaseSettingsFragment();
Bundle arguments = new Bundle();
arguments.putSerializable(CONFIGURATION_ARGUMENT, configuration);
fragment.setArguments(arguments);
return fragment;
}
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.base_settings_fragment, container, false);
recycler = view.findViewById(R.id.base_settings_list);
recycler.setItemAnimator(null);
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
BaseSettingsAdapter adapter = new BaseSettingsAdapter();
recycler.setLayoutManager(new LinearLayoutManager(requireContext()));
recycler.setAdapter(adapter);
Configuration configuration = (Configuration) Objects.requireNonNull(requireArguments().getSerializable(CONFIGURATION_ARGUMENT));
configuration.configure(requireActivity(), adapter);
configuration.setArguments(getArguments());
configuration.configureAdapter(adapter);
adapter.submitList(configuration.getSettings());
}
/**
* A configuration for a settings screen. Utilizes serializable to hide
* reflection of instantiating from a fragment argument.
*/
public static abstract class Configuration implements Serializable {
protected transient FragmentActivity activity;
protected transient BaseSettingsAdapter adapter;
public void configure(@NonNull FragmentActivity activity, @NonNull BaseSettingsAdapter adapter) {
this.activity = activity;
this.adapter = adapter;
}
/**
* Retrieve any runtime information from the fragment's arguments.
*/
public void setArguments(@Nullable Bundle arguments) {}
protected void updateSettingsList() {
adapter.submitList(getSettings());
}
public abstract void configureAdapter(@NonNull BaseSettingsAdapter adapter);
public abstract @NonNull MappingModelList getSettings();
}
}

View File

@@ -1,72 +0,0 @@
package org.thoughtcrime.securesms.components.settings;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.Group;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel;
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder;
import java.util.Objects;
/**
* Adds ability to customize a value for a single select (radio) setting.
*/
public class CustomizableSingleSelectSetting {
public interface CustomizableSingleSelectionListener extends SingleSelectSetting.SingleSelectSelectionChangedListener {
void onCustomizeClicked(@Nullable Item item);
}
public static class ViewHolder extends MappingViewHolder<Item> {
private final View customize;
private final SingleSelectSetting.ViewHolder delegate;
private final Group customizeGroup;
private final CustomizableSingleSelectionListener selectionListener;
public ViewHolder(@NonNull View itemView, @NonNull CustomizableSingleSelectionListener selectionListener) {
super(itemView);
this.selectionListener = selectionListener;
customize = findViewById(R.id.customizable_single_select_customize);
customizeGroup = findViewById(R.id.customizable_single_select_customize_group);
delegate = new SingleSelectSetting.ViewHolder(itemView, selectionListener);
}
@Override
public void bind(@NonNull Item model) {
delegate.bind(model.singleSelectItem);
customizeGroup.setVisibility(model.singleSelectItem.isSelected() ? View.VISIBLE : View.GONE);
customize.setOnClickListener(v -> selectionListener.onCustomizeClicked(model));
}
}
public static class Item implements MappingModel<Item> {
private final SingleSelectSetting.Item singleSelectItem;
private final Object customValue;
public <T> Item(@NonNull T item, @Nullable String text, boolean isSelected, @Nullable Object customValue, @Nullable String summaryText) {
this.customValue = customValue;
singleSelectItem = new SingleSelectSetting.Item(item, text, summaryText, isSelected);
}
public @Nullable Object getCustomValue() {
return customValue;
}
@Override
public boolean areItemsTheSame(@NonNull Item newItem) {
return singleSelectItem.areItemsTheSame(newItem.singleSelectItem);
}
@Override
public boolean areContentsTheSame(@NonNull Item newItem) {
return singleSelectItem.areContentsTheSame(newItem.singleSelectItem) && Objects.equals(customValue, newItem.customValue);
}
}
}

View File

@@ -215,15 +215,15 @@ class ChangeNumberRepository(
@WorkerThread
fun changeLocalNumber(e164: String, pni: PNI): Single<Unit> {
val oldStorageId: ByteArray? = Recipient.self().storageServiceId
val oldStorageId: ByteArray? = Recipient.self().storageId
SignalDatabase.recipients.updateSelfE164(e164, pni)
val newStorageId: ByteArray? = Recipient.self().storageServiceId
val newStorageId: ByteArray? = Recipient.self().storageId
if (e164 != SignalStore.account().requireE164() && MessageDigest.isEqual(oldStorageId, newStorageId)) {
Log.w(TAG, "Self storage id was not rotated, attempting to rotate again")
SignalDatabase.recipients.rotateStorageId(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
val secondAttemptStorageId: ByteArray? = Recipient.self().storageServiceId
val secondAttemptStorageId: ByteArray? = Recipient.self().storageId
if (MessageDigest.isEqual(oldStorageId, secondAttemptStorageId)) {
Log.w(TAG, "Second attempt also failed to rotate storage id")
}

View File

@@ -1,12 +1,15 @@
package org.thoughtcrime.securesms.components.settings.app.chats
import android.content.Intent
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.Navigation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowActivity
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -81,9 +84,23 @@ class ChatsSettingsFragment : DSLSettingsFragment(R.string.preferences_chats__ch
sectionHeaderPref(R.string.preferences_chats__backups)
if (FeatureFlags.messageBackups() || state.remoteBackupsEnabled) {
clickPref(
title = DSLSettingsText.from("Signal Backups"), // TODO [message-backups] -- Finalized copy
summary = DSLSettingsText.from(if (state.remoteBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
onClick = {
if (state.remoteBackupsEnabled) {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_remoteBackupsSettingsFragment)
} else {
startActivity(Intent(requireContext(), MessageBackupsFlowActivity::class.java))
}
}
)
}
clickPref(
title = DSLSettingsText.from(R.string.preferences_chats__chat_backups),
summary = DSLSettingsText.from(if (state.chatBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
summary = DSLSettingsText.from(if (state.localBackupsEnabled) R.string.arrays__enabled else R.string.arrays__disabled),
onClick = {
Navigation.findNavController(requireView()).safeNavigate(R.id.action_chatsSettingsFragment_to_backupsPreferenceFragment)
}

View File

@@ -6,5 +6,6 @@ data class ChatsSettingsState(
val keepMutedChatsArchived: Boolean,
val useSystemEmoji: Boolean,
val enterKeySends: Boolean,
val chatBackupsEnabled: Boolean
val localBackupsEnabled: Boolean,
val remoteBackupsEnabled: Boolean
)

View File

@@ -22,7 +22,8 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
useSystemEmoji = SignalStore.settings().isPreferSystemEmoji,
enterKeySends = SignalStore.settings().isEnterKeySends,
chatBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
localBackupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication()),
remoteBackupsEnabled = SignalStore.backup().areBackupsEnabled
)
)
@@ -59,8 +60,12 @@ class ChatsSettingsViewModel @JvmOverloads constructor(
fun refresh() {
val backupsEnabled = SignalStore.settings().isBackupEnabled && BackupUtil.canUserAccessBackupDirectory(ApplicationDependencies.getApplication())
if (store.state.chatBackupsEnabled != backupsEnabled) {
store.update { it.copy(chatBackupsEnabled = backupsEnabled) }
val remoteBackupsEnabled = SignalStore.backup().areBackupsEnabled
if (store.state.localBackupsEnabled != backupsEnabled ||
store.state.remoteBackupsEnabled != remoteBackupsEnabled
) {
store.update { it.copy(localBackupsEnabled = backupsEnabled, remoteBackupsEnabled = remoteBackupsEnabled) }
}
}
}

View File

@@ -0,0 +1,636 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
import android.content.Intent
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
import org.signal.core.ui.Dividers
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.Snackbars
import org.signal.core.ui.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowActivity
import org.thoughtcrime.securesms.backup.v2.ui.subscription.getTierDetails
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
import java.util.Locale
/**
* Remote backups settings fragment.
*
* TODO [message-backups] -- All copy in this file is non-final
*/
class RemoteBackupsSettingsFragment : ComposeFragment() {
private val viewModel by viewModel {
RemoteBackupsSettingsViewModel()
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
val callbacks = remember { Callbacks() }
RemoteBackupsSettingsContent(
messageBackupTier = state.messageBackupsTier,
lastBackupTimestamp = state.lastBackupTimestamp,
canBackUpUsingCellular = state.canBackUpUsingCellular,
backupsFrequency = state.backupsFrequency,
requestedDialog = state.dialog,
requestedSnackbar = state.snackbar,
contentCallbacks = callbacks,
backupProgress = state.backupProgress,
backupSize = state.backupSize
)
}
@Stable
private inner class Callbacks : ContentCallbacks {
override fun onNavigationClick() {
findNavController().popBackStack()
}
override fun onEnableBackupsClick() {
startActivity(Intent(requireContext(), MessageBackupsFlowActivity::class.java))
}
override fun onBackUpUsingCellularClick(canUseCellular: Boolean) {
viewModel.setCanBackUpUsingCellular(canUseCellular)
}
override fun onViewPaymentHistory() {
// TODO [message-backups] Navigate to payment history
}
override fun onBackupNowClick() {
viewModel.onBackupNowClick()
}
override fun onTurnOffAndDeleteBackupsClick() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS)
}
override fun onChangeBackupFrequencyClick() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY)
}
override fun onDialogDismissed() {
viewModel.requestDialog(RemoteBackupsSettingsState.Dialog.NONE)
}
override fun onSnackbarDismissed() {
viewModel.requestSnackbar(RemoteBackupsSettingsState.Snackbar.NONE)
}
override fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) {
viewModel.setBackupsFrequency(newFrequency)
}
override fun onTurnOffAndDeleteBackupsConfirm() {
viewModel.turnOffAndDeleteBackups()
}
override fun onBackupsTypeClick() {
findNavController().safeNavigate(R.id.action_remoteBackupsSettingsFragment_to_backupsTypeSettingsFragment)
}
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onEvent(backupEvent: BackupV2Event) {
viewModel.updateBackupProgress(backupEvent)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner)
}
}
/**
* Callback interface for RemoteBackupsSettingsContent composable.
*/
private interface ContentCallbacks {
fun onNavigationClick() = Unit
fun onEnableBackupsClick() = Unit
fun onBackupsTypeClick() = Unit
fun onBackUpUsingCellularClick(canUseCellular: Boolean) = Unit
fun onViewPaymentHistory() = Unit
fun onBackupNowClick() = Unit
fun onTurnOffAndDeleteBackupsClick() = Unit
fun onChangeBackupFrequencyClick() = Unit
fun onDialogDismissed() = Unit
fun onSnackbarDismissed() = Unit
fun onSelectBackupsFrequencyChange(newFrequency: BackupFrequency) = Unit
fun onTurnOffAndDeleteBackupsConfirm() = Unit
}
@Composable
private fun RemoteBackupsSettingsContent(
messageBackupTier: MessageBackupTier?,
lastBackupTimestamp: Long,
canBackUpUsingCellular: Boolean,
backupsFrequency: BackupFrequency,
requestedDialog: RemoteBackupsSettingsState.Dialog,
requestedSnackbar: RemoteBackupsSettingsState.Snackbar,
contentCallbacks: ContentCallbacks,
backupProgress: BackupV2Event?,
backupSize: Long
) {
val snackbarHostState = remember {
SnackbarHostState()
}
Scaffolds.Settings(
title = "Signal Backups",
onNavigationClick = contentCallbacks::onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
snackbarHost = {
Snackbars.Host(snackbarHostState = snackbarHostState)
}
) {
LazyColumn(
modifier = Modifier
.padding(it)
) {
item {
BackupTypeRow(
messageBackupTier = messageBackupTier,
onEnableBackupsClick = contentCallbacks::onEnableBackupsClick,
onChangeBackupsTypeClick = contentCallbacks::onBackupsTypeClick
)
}
if (messageBackupTier == null) {
item {
Rows.TextRow(
text = "Payment history",
onClick = contentCallbacks::onViewPaymentHistory
)
}
} else {
item {
Dividers.Default()
}
item {
Texts.SectionHeader(text = "Backup Details")
}
if (backupProgress == null || backupProgress.type == BackupV2Event.Type.FINISHED) {
item {
LastBackupRow(
lastBackupTimestamp = lastBackupTimestamp,
onBackupNowClick = contentCallbacks::onBackupNowClick
)
}
} else {
item {
InProgressBackupRow(progress = backupProgress.count.toInt(), totalProgress = backupProgress.estimatedTotalCount.toInt())
}
}
item {
Rows.TextRow(text = {
Column {
Text(
text = "Backup size",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = Util.getPrettyFileSize(backupSize ?: 0),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
item {
Rows.TextRow(
text = {
Column {
Text(
text = "Backup frequency",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = getTextForFrequency(backupsFrequency = backupsFrequency),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = contentCallbacks::onChangeBackupFrequencyClick
)
}
item {
Rows.ToggleRow(
checked = canBackUpUsingCellular,
text = "Back up using cellular",
onCheckChanged = contentCallbacks::onBackUpUsingCellularClick
)
}
item {
Dividers.Default()
}
item {
Rows.TextRow(
text = "Turn off and delete backup",
foregroundTint = MaterialTheme.colorScheme.error,
onClick = contentCallbacks::onTurnOffAndDeleteBackupsClick
)
}
}
}
}
when (requestedDialog) {
RemoteBackupsSettingsState.Dialog.NONE -> {}
RemoteBackupsSettingsState.Dialog.TURN_OFF_AND_DELETE_BACKUPS -> {
TurnOffAndDeleteBackupsDialog(
onConfirm = contentCallbacks::onTurnOffAndDeleteBackupsConfirm,
onDismiss = contentCallbacks::onDialogDismissed
)
}
RemoteBackupsSettingsState.Dialog.BACKUP_FREQUENCY -> {
BackupFrequencyDialog(
selected = backupsFrequency,
onSelected = contentCallbacks::onSelectBackupsFrequencyChange,
onDismiss = contentCallbacks::onDialogDismissed
)
}
}
LaunchedEffect(requestedSnackbar) {
when (requestedSnackbar) {
RemoteBackupsSettingsState.Snackbar.NONE -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF -> {
snackbarHostState.showSnackbar(
"Backup deleted and turned off"
)
}
RemoteBackupsSettingsState.Snackbar.BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED -> {
snackbarHostState.showSnackbar(
"Backup type changed and subscription cancelled"
)
}
RemoteBackupsSettingsState.Snackbar.SUBSCRIPTION_CANCELLED -> {
snackbarHostState.showSnackbar(
"Subscription cancelled"
)
}
RemoteBackupsSettingsState.Snackbar.DOWNLOAD_COMPLETE -> {
snackbarHostState.showSnackbar(
"Download complete"
)
}
}
contentCallbacks.onSnackbarDismissed()
}
}
@Composable
private fun BackupTypeRow(
messageBackupTier: MessageBackupTier?,
onEnableBackupsClick: () -> Unit,
onChangeBackupsTypeClick: () -> Unit
) {
val messageBackupsType = if (messageBackupTier != null) getTierDetails(messageBackupTier) else null
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = messageBackupTier != null, onClick = onChangeBackupsTypeClick)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 16.dp, bottom = 14.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Backup type",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (messageBackupsType == null) {
Text(
text = "Backups disabled",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
val localResources = LocalContext.current.resources
val formattedCurrency = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(localResources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
text = "${messageBackupsType.title} · $formattedCurrency/month"
)
}
}
if (messageBackupsType == null) {
Buttons.Small(onClick = onEnableBackupsClick) {
Text(text = "Enable backups")
}
}
}
}
@Composable
private fun InProgressBackupRow(
progress: Int?,
totalProgress: Int?
) {
Row(
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 16.dp, bottom = 14.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
if (totalProgress == null || totalProgress == 0) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), progress = ((progress ?: 0) / totalProgress).toFloat())
}
Text(
text = "$progress/$totalProgress",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun LastBackupRow(
lastBackupTimestamp: Long,
onBackupNowClick: () -> Unit
) {
Row(
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.padding(top = 16.dp, bottom = 14.dp)
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "Last backup",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface
)
if (lastBackupTimestamp > 0) {
val context = LocalContext.current
val day = remember(lastBackupTimestamp) {
DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
}
val time = remember(lastBackupTimestamp) {
DateUtils.getOnlyTimeString(context, lastBackupTimestamp)
}
Text(
text = "$day at $time",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "Never",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Buttons.Small(onClick = onBackupNowClick) {
Text(text = "Back up now")
}
}
}
@Composable
private fun TurnOffAndDeleteBackupsDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = "Turn off and delete backups?",
body = "You will not be charged again. Your backup will be deleted and no new backups will be created.",
confirm = "Turn off and delete",
dismiss = stringResource(id = android.R.string.cancel),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = onConfirm,
onDismiss = onDismiss
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BackupFrequencyDialog(
selected: BackupFrequency,
onSelected: (BackupFrequency) -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss
) {
Surface {
Column(
modifier = Modifier
.background(
color = AlertDialogDefaults.containerColor,
shape = AlertDialogDefaults.shape
)
.fillMaxWidth()
) {
Text(
text = "Backup frequency",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(24.dp)
)
BackupFrequency.values().forEach {
Rows.RadioRow(
selected = selected == it,
text = getTextForFrequency(backupsFrequency = it),
label = when (it) {
BackupFrequency.MANUAL -> "By tapping \"Back up now\""
else -> null
},
modifier = Modifier
.padding(end = 24.dp)
.clickable(onClick = {
onSelected(it)
onDismiss()
})
)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp)
) {
TextButton(onClick = onDismiss) {
Text(text = stringResource(id = android.R.string.cancel))
}
}
}
}
}
}
@Composable
private fun getTextForFrequency(backupsFrequency: BackupFrequency): String {
return when (backupsFrequency) {
BackupFrequency.DAILY -> "Daily"
BackupFrequency.WEEKLY -> "Weekly"
BackupFrequency.MONTHLY -> "Monthly"
BackupFrequency.MANUAL -> "Manually back up"
}
}
@SignalPreview
@Composable
private fun RemoteBackupsSettingsContentPreview() {
Previews.Preview {
RemoteBackupsSettingsContent(
messageBackupTier = null,
lastBackupTimestamp = -1,
canBackUpUsingCellular = false,
backupsFrequency = BackupFrequency.MANUAL,
requestedDialog = RemoteBackupsSettingsState.Dialog.NONE,
requestedSnackbar = RemoteBackupsSettingsState.Snackbar.NONE,
contentCallbacks = object : ContentCallbacks {},
backupProgress = null,
backupSize = 2300000
)
}
}
@SignalPreview
@Composable
private fun BackupTypeRowPreview() {
Previews.Preview {
BackupTypeRow(
messageBackupTier = MessageBackupTier.PAID,
onChangeBackupsTypeClick = {},
onEnableBackupsClick = {}
)
}
}
@SignalPreview
@Composable
private fun LastBackupRowPreview() {
Previews.Preview {
LastBackupRow(
lastBackupTimestamp = -1,
onBackupNowClick = {}
)
}
}
@SignalPreview
@Composable
private fun InProgressRowPreview() {
Previews.Preview {
InProgressBackupRow(50, 100)
}
}
@SignalPreview
@Composable
private fun TurnOffAndDeleteBackupsDialogPreview() {
Previews.Preview {
TurnOffAndDeleteBackupsDialog(
onConfirm = {},
onDismiss = {}
)
}
}
@SignalPreview
@Composable
private fun BackupFrequencyDialogPreview() {
Previews.Preview {
BackupFrequencyDialog(
selected = BackupFrequency.DAILY,
onSelected = {},
onDismiss = {}
)
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
data class RemoteBackupsSettingsState(
val messageBackupsTier: MessageBackupTier? = null,
val canBackUpUsingCellular: Boolean = false,
val backupSize: Long = 0,
val backupsFrequency: BackupFrequency = BackupFrequency.DAILY,
val lastBackupTimestamp: Long = 0,
val dialog: Dialog = Dialog.NONE,
val snackbar: Snackbar = Snackbar.NONE,
val backupProgress: BackupV2Event? = null
) {
enum class Dialog {
NONE,
TURN_OFF_AND_DELETE_BACKUPS,
BACKUP_FREQUENCY
}
enum class Snackbar {
NONE,
BACKUP_DELETED_AND_TURNED_OFF,
BACKUP_TYPE_CHANGED_AND_SUBSCRIPTION_CANCELLED,
SUBSCRIPTION_CANCELLED,
DOWNLOAD_COMPLETE
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.backup.v2.BackupFrequency
import org.thoughtcrime.securesms.backup.v2.BackupV2Event
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.MessageBackupListener
/**
* ViewModel for state management of RemoteBackupsSettingsFragment
*/
class RemoteBackupsSettingsViewModel : ViewModel() {
private val internalState = mutableStateOf(
RemoteBackupsSettingsState(
messageBackupsTier = SignalStore.backup().backupTier,
lastBackupTimestamp = SignalStore.backup().lastBackupTime,
backupSize = SignalStore.backup().totalBackupSize,
backupsFrequency = SignalStore.backup().backupFrequency
)
)
val state: State<RemoteBackupsSettingsState> = internalState
fun setCanBackUpUsingCellular(canBackUpUsingCellular: Boolean) {
SignalStore.backup().backupWithCellular = canBackUpUsingCellular
internalState.value = state.value.copy(canBackUpUsingCellular = canBackUpUsingCellular)
}
fun setBackupsFrequency(backupsFrequency: BackupFrequency) {
SignalStore.backup().backupFrequency = backupsFrequency
internalState.value = state.value.copy(backupsFrequency = backupsFrequency)
MessageBackupListener.setNextBackupTimeToIntervalFromNow()
MessageBackupListener.schedule(ApplicationDependencies.getApplication())
}
fun requestDialog(dialog: RemoteBackupsSettingsState.Dialog) {
internalState.value = state.value.copy(dialog = dialog)
}
fun requestSnackbar(snackbar: RemoteBackupsSettingsState.Snackbar) {
internalState.value = state.value.copy(snackbar = snackbar)
}
fun turnOffAndDeleteBackups() {
// TODO [message-backups] -- Delete.
SignalStore.backup().areBackupsEnabled = false
internalState.value = state.value.copy(snackbar = RemoteBackupsSettingsState.Snackbar.BACKUP_DELETED_AND_TURNED_OFF)
}
fun updateBackupProgress(backupEvent: BackupV2Event?) {
internalState.value = state.value.copy(backupProgress = backupEvent)
refreshBackupState()
}
private fun refreshBackupState() {
internalState.value = state.value.copy(
lastBackupTimestamp = SignalStore.backup().lastBackupTime,
backupSize = SignalStore.backup().totalBackupSize
)
}
fun onBackupNowClick() {
if (state.value.backupProgress == null || state.value.backupProgress?.type == BackupV2Event.Type.FINISHED) {
BackupMessagesJob.enqueue()
}
}
}

View File

@@ -0,0 +1,194 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.type
import android.content.Intent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Previews
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.SignalPreview
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsFlowActivity
import org.thoughtcrime.securesms.backup.v2.ui.subscription.getTierDetails
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.viewModel
import java.util.Locale
/**
* Allows the user to modify their backup plan
*/
class BackupsTypeSettingsFragment : ComposeFragment() {
private val viewModel: BackupsTypeSettingsViewModel by viewModel {
BackupsTypeSettingsViewModel()
}
@Composable
override fun FragmentContent() {
val contentCallbacks = remember {
Callbacks()
}
val state by viewModel.state
BackupsTypeSettingsContent(
state = state,
contentCallbacks = contentCallbacks
)
}
private inner class Callbacks : ContentCallbacks {
override fun onNavigationClick() {
findNavController().popBackStack()
}
override fun onPaymentHistoryClick() {
// TODO [message-backups] Navigate to payment history
}
override fun onChangeOrCancelSubscriptionClick() {
startActivity(Intent(requireContext(), MessageBackupsFlowActivity::class.java))
}
}
}
private interface ContentCallbacks {
fun onNavigationClick() = Unit
fun onPaymentHistoryClick() = Unit
fun onChangeOrCancelSubscriptionClick() = Unit
}
@Composable
private fun BackupsTypeSettingsContent(
state: BackupsTypeSettingsState,
contentCallbacks: ContentCallbacks
) {
if (state.backupsTier == null) {
return
}
Scaffolds.Settings(
title = "Backup Type",
onNavigationClick = contentCallbacks::onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
LazyColumn(
modifier = Modifier.padding(it)
) {
item {
BackupsTypeRow(
backupsTier = state.backupsTier,
nextRenewalTimestamp = state.nextRenewalTimestamp
)
}
item {
PaymentSourceRow(
paymentSourceType = state.paymentSourceType
)
}
item {
Rows.TextRow(
text = "Change or cancel subscription", // TODO [message-backups] final copy
onClick = contentCallbacks::onChangeOrCancelSubscriptionClick
)
}
item {
Rows.TextRow(
text = "Payment history", // TODO [message-backups] final copy
onClick = contentCallbacks::onPaymentHistoryClick
)
}
}
}
}
@Composable
private fun BackupsTypeRow(
backupsTier: MessageBackupTier,
nextRenewalTimestamp: Long
) {
val messageBackupsType = remember {
getTierDetails(backupsTier)
}
val resources = LocalContext.current.resources
val formattedAmount = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
val renewal = remember(nextRenewalTimestamp) {
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), nextRenewalTimestamp)
}
Rows.TextRow(text = {
Column {
Text(text = messageBackupsType.title)
Text(
text = "$formattedAmount/month . Renews $renewal", // TODO [message-backups] final copy
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
@Composable
private fun PaymentSourceRow(paymentSourceType: PaymentSourceType) {
val paymentSourceTextResId = remember(paymentSourceType) {
when (paymentSourceType) {
is PaymentSourceType.Stripe.CreditCard -> R.string.BackupsTypeSettingsFragment__credit_or_debit_card
is PaymentSourceType.Stripe.IDEAL -> R.string.BackupsTypeSettingsFragment__iDEAL
is PaymentSourceType.Stripe.GooglePay -> R.string.BackupsTypeSettingsFragment__google_pay
is PaymentSourceType.Stripe.SEPADebit -> R.string.BackupsTypeSettingsFragment__bank_transfer
is PaymentSourceType.PayPal -> R.string.BackupsTypeSettingsFragment__paypal
is PaymentSourceType.Unknown -> R.string.BackupsTypeSettingsFragment__unknown
}
}
Rows.TextRow(text = {
Column {
Text(text = "Payment method") // TOD [message-backups] Final copy
Text(
text = stringResource(id = paymentSourceTextResId),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
})
}
@SignalPreview
@Composable
private fun BackupsTypeSettingsContentPreview() {
Previews.Preview {
BackupsTypeSettingsContent(
state = BackupsTypeSettingsState(
backupsTier = MessageBackupTier.PAID
),
contentCallbacks = object : ContentCallbacks {}
)
}
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.chats.backups.type
import androidx.compose.runtime.Stable
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
@Stable
data class BackupsTypeSettingsState(
val backupsTier: MessageBackupTier? = null,
val paymentSourceType: PaymentSourceType = PaymentSourceType.Unknown,
val nextRenewalTimestamp: Long = 0
)

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