Compare commits

..

243 Commits

Author SHA1 Message Date
Greyson Parrelli
e87b73cc19 Bump version to 7.12.1 2024-07-19 16:09:27 -04:00
Greyson Parrelli
45e1ecd07e Update baseline profile. 2024-07-19 16:09:05 -04:00
Greyson Parrelli
7b043d4143 Update translations and other static files. 2024-07-19 15:50:45 -04:00
Alex Hart
046d439887 Do not restrict parcelized type. 2024-07-19 16:32:29 -03:00
Greyson Parrelli
b34bf4b8b0 Initialize app dependencies earlier. 2024-07-18 12:03:23 -04:00
Nicholas Tinsley
fddc99ab4f Remove noise log statement from ExoPlayer. 2024-07-18 11:07:27 -04:00
Rashad Sookram
1007111310 Update to RingRTC v2.44.4 2024-07-18 08:54:54 -04:00
Greyson Parrelli
3346a1e918 Bump version to 7.12.0 2024-07-17 15:44:50 -04:00
Greyson Parrelli
9c391eb2c9 Update baseline profile. 2024-07-17 15:44:50 -04:00
Greyson Parrelli
f714e038a0 Update translations and other static files. 2024-07-17 15:30:28 -04:00
Greyson Parrelli
61405a62c2 Fix NPE in exoplayer transfer listener. 2024-07-17 15:21:14 -04:00
Alex Hart
d424a60345 Fix group call flickering missed. 2024-07-17 15:21:14 -04:00
Clark
3c10966a36 Add logging for Conversation activity restart due to config changes. 2024-07-17 15:21:14 -04:00
Clark
e210d5939c Dismiss battery saver prompt on continue. 2024-07-17 15:21:14 -04:00
Cody Henthorne
1fafcc69ff Improve large upload over slow connections. 2024-07-17 15:21:14 -04:00
Clark
3184368fa7 Various backup/restore bug fixes. 2024-07-17 15:21:14 -04:00
Greyson Parrelli
c622b7fdb1 Remove all legacy uploads to cdn0. 2024-07-17 15:21:14 -04:00
Alex Hart
29ead80e17 Remove outline on generated call link preview. 2024-07-17 15:21:14 -04:00
Alex Hart
9a72833e06 Filter out outgoing calls and call links in missed call filter. 2024-07-17 15:21:14 -04:00
Alex Hart
d0baf1dc95 Remove appbar offset listener when call log is unbound. 2024-07-17 15:21:14 -04:00
Michelle Tang
7f1227ee19 Add discard draft confirmation when editing. 2024-07-16 17:08:10 -07:00
Nicholas Tinsley
34c95dc082 Audio device logging. 2024-07-16 17:57:05 -04:00
Nicholas Tinsley
10ad73f201 Add logging around data source reading. 2024-07-16 17:48:47 -04:00
Nicholas Tinsley
21fab7c5ba Fix backing out of group story text send. 2024-07-16 17:36:14 -04:00
Nicholas Tinsley
e9c2f96bb9 Increase logging around attachment compression/upload job lifecycle. 2024-07-16 17:31:45 -04:00
Nicholas Tinsley
a950462451 Apply phone number formatting immediately. 2024-07-16 16:54:39 -04:00
Nicholas Tinsley
809317c0fd Run registration UI callbacks in LifeycleScope. 2024-07-16 16:54:39 -04:00
Cody Henthorne
6634540183 Fix generation and update baseline profile. 2024-07-16 16:54:39 -04:00
Alex Hart
89bfba3ee9 Backups subscription flow odds and ends. 2024-07-16 16:54:39 -04:00
Alex Konradi
97974291d2 Update to libsignal 0.52.2 2024-07-16 16:54:38 -04:00
Cody Henthorne
6daee5719b Allow for larger input videos for sending. 2024-07-16 16:54:38 -04:00
Clark
58443c46be Fix username constraint on re-reg from prod->staging. 2024-07-16 16:54:38 -04:00
Nicholas Tinsley
8cbecc2992 Upgrade libphonenumber to 8.13.40. 2024-07-16 16:54:38 -04:00
Cody Henthorne
4c0ca48af3 Handle ChatServiceException in response processors. 2024-07-16 16:54:38 -04:00
Greyson Parrelli
91eeda6c6e Allow RemoteConfig to be lazily initialized. 2024-07-16 16:54:38 -04:00
Greyson Parrelli
04e75c18dd Bump version to 7.11.3 2024-07-16 16:48:08 -04:00
Greyson Parrelli
f5fbfbc7fd Update translations and other static files. 2024-07-16 16:40:44 -04:00
Cody Henthorne
0e1df94b54 Fix processing incoming group invites. 2024-07-16 15:45:22 -04:00
Cody Henthorne
dd923629f6 Bump version to 7.11.2 2024-07-12 17:02:55 -04:00
Cody Henthorne
df8992aaca Update translations and other static files. 2024-07-12 16:57:01 -04:00
Cody Henthorne
63b9700865 Fix group sync message sent bug. 2024-07-12 16:48:12 -04:00
Nicholas Tinsley
d309877d63 Fix ISE in registration. 2024-07-12 16:47:51 -04:00
Nicholas Tinsley
f247fd78c6 Constant bitrate video encoding. 2024-07-12 11:01:03 -04:00
Cody Henthorne
4f96cb7439 Bump version to 7.11.1 2024-07-11 16:25:44 -04:00
Cody Henthorne
6e6e3a5eba Update translations and other static files. 2024-07-11 16:14:04 -04:00
Nicholas Tinsley
9166ed64fb Fix crash in nickname update button. 2024-07-11 16:02:15 -04:00
Nicholas Tinsley
25e4eaa8e8 Log successes in audio device switching. 2024-07-10 13:24:15 -04:00
Nicholas Tinsley
6e742ce770 Prevent crash from showing bottom sheet. 2024-07-09 18:20:15 -04:00
Nicholas Tinsley
c134c3033e Prevent ISE with view binding in registration. 2024-07-09 18:20:15 -04:00
Nicholas Tinsley
fb43a8257c Add more logging for rate limited scenarios. 2024-07-09 16:54:30 -04:00
Nicholas Tinsley
cecfe80d61 Simplify reg v2 nav graph names. 2024-07-09 16:13:22 -04:00
Cody Henthorne
6c302b708a Bump version to 7.11.0 2024-07-09 13:55:58 -04:00
Cody Henthorne
6a22919c50 Update baseline profile. 2024-07-09 13:49:15 -04:00
Cody Henthorne
9073ce5c7b Update translations and other static files. 2024-07-09 13:46:48 -04:00
Michelle Tang
9024c19169 Update device-specific notification support configs. 2024-07-09 13:40:41 -04:00
Arthur-GYT
60a0565ba8 Show max edits warning before editing.
Fixes #13428
Closes #13615

Signed-off-by: Arthur-GYT <a.gayot@ik.me>
2024-07-09 13:40:41 -04:00
Cody Henthorne
383f7556e3 Fix delete for everyone dialog option in note to self. 2024-07-09 13:40:41 -04:00
Greyson Parrelli
94795599e2 Inline the delete sync feature flag. 2024-07-09 13:40:41 -04:00
Nicholas Tinsley
84fbb7c466 Update "lower hand" label to "lower" 2024-07-09 13:40:41 -04:00
Cody Henthorne
c339f39b70 Fix camera manager memory leak. 2024-07-09 13:40:41 -04:00
Cody Henthorne
41a3609f06 Fix edit quote draft loading incorrectly bug. 2024-07-09 13:40:41 -04:00
Cody Henthorne
f5abd7acdf Add Group Send Endorsements support. 2024-07-09 13:40:41 -04:00
Nicholas Tinsley
414368e251 Prevent crash when trying to display Contact Support bottom sheet multiple times. 2024-07-09 13:40:41 -04:00
Jim Gustafson
a3d1197aef Update to RingRTC v2.44.3 2024-07-09 13:40:41 -04:00
Nicholas Tinsley
d91760eefc Upgrade AndroidX Media3 to 1.3.1. 2024-07-09 13:40:41 -04:00
Greyson Parrelli
ee20ced238 Switch MediaName to hex encoding. 2024-07-09 13:40:41 -04:00
Alex Hart
066892c11a Make group subtitles auto-update as names change. 2024-07-09 13:40:41 -04:00
Alex Hart
69fd4f79db Stop reading redemptionTime field. 2024-07-09 13:40:41 -04:00
Alex Hart
f49e2768c1 Fix crash in review card repository. 2024-07-09 13:40:41 -04:00
Greyson Parrelli
70378b85d7 Remove unused capabilities. 2024-07-09 13:40:41 -04:00
Nicholas Tinsley
585401a98e Do not download attachment if we do not have a digest. 2024-07-09 13:40:41 -04:00
Alex Hart
cf7ebfa03d Do not mark update unread if user was ever in the call. 2024-07-09 13:40:41 -04:00
Nicholas Tinsley
aec0a9951a Prevent backups from being scheduled twice within the jitter window.
Fixes #13559.
2024-07-09 13:40:41 -04:00
Nicholas Tinsley
b113eec940 Show "Update" action button on profile name change. 2024-07-09 13:40:41 -04:00
Michelle Tang
a966812bfc Add full send attachments. 2024-07-09 13:40:41 -04:00
Alex Hart
3879a8ffdb Pluralize backup strings. 2024-07-09 13:40:41 -04:00
Alex Hart
5b949b0116 Fix call id serialization. 2024-07-09 13:40:41 -04:00
Rashad Sookram
3c13619ce8 Update to RingRTC v2.44.2 2024-07-09 13:40:41 -04:00
Cody Henthorne
24bba98122 Fix sync thread delete sending another sync back. 2024-07-09 13:40:41 -04:00
Cody Henthorne
a96e5e6ae6 Fix delete sync capability updating on linked devices. 2024-07-09 13:40:41 -04:00
Alex Hart
4cfdfab31e Rename more in-app-payment classes. 2024-07-09 13:40:41 -04:00
Alex Hart
77d3116431 Rename DonationValues to InAppPaymentValues. 2024-07-09 13:40:41 -04:00
Alex Hart
b943df1ce4 Add translatable copy for backup alert fragment. 2024-07-09 13:40:41 -04:00
Alex Hart
8bbb7d56e0 Implements a bunch of missing things in the backup checkout flow stuff. 2024-07-09 13:40:41 -04:00
Clark
079a3d4fee Add import/export tests for contact messages and link previews. 2024-07-09 13:40:41 -04:00
Jim Gustafson
176e0e7765 Update to RingRTC v2.44.1 2024-07-09 13:40:41 -04:00
Greyson Parrelli
c73e80f8d9 Include username link entropy in backups. 2024-07-09 13:40:41 -04:00
Greyson Parrelli
47cd1b568f Add lock screen help dialog. 2024-07-09 13:40:41 -04:00
Clark
058c523329 Add support for import/export of shared contact messages. 2024-07-09 13:40:40 -04:00
Clark
84515482a6 Message backup support for link previews. 2024-07-09 13:40:40 -04:00
Chris Eager
02629020df Remove Option.RECAPTCHA from ProofRequiredException. 2024-07-09 13:40:40 -04:00
Clark
58d769b21f Allow exporting backup tests to binproto. 2024-07-09 13:40:40 -04:00
Greyson Parrelli
9dc67e0466 Do not use quote contents in edit sync messages.
Resolves #13571
2024-07-09 13:40:40 -04:00
Greyson Parrelli
72d02104dc Move StringExtensions to core-util-jvm. 2024-07-09 13:40:40 -04:00
Cody Henthorne
371a39049d Ignore flakey delete sync test. 2024-07-03 14:02:59 -04:00
Clark
47e4a6cf5a Regularly delete any archived media we don't know about. 2024-07-03 14:02:59 -04:00
Clark Chen
4a41e9f9a1 Bump version to 7.10.3 2024-07-02 11:21:45 -04:00
Clark Chen
9fa1b58019 Update translations and other static files. 2024-07-02 11:21:45 -04:00
Michelle Tang
c24473e176 Fix default name for linked devices. 2024-07-01 16:44:53 -04:00
mtang-signal
1311ec498f Default to back camera when linking device. 2024-07-01 08:59:35 -04:00
Nicholas Tinsley
251cec5dee Bump version to 7.10.2 2024-06-28 15:23:01 -04:00
Nicholas Tinsley
1e15a8c1d3 Update translations and other static files. 2024-06-28 15:22:12 -04:00
Michelle Tang
9c5c58794b Fix invalid qr code crash. 2024-06-27 16:12:38 -04:00
Nicholas Tinsley
50063854d7 Bump version to 7.10.1 2024-06-26 13:57:39 -04:00
Nicholas Tinsley
79d2041e46 Update translations and other static files. 2024-06-26 13:45:34 -04:00
Nicholas Tinsley
479b27ce94 Fix benchmark tests. 2024-06-26 13:39:28 -04:00
Cody Henthorne
a66857a7cc Fix incorrect local group state bug. 2024-06-26 13:39:28 -04:00
Alex Hart
37815a3f39 Fix bug with cut off avatars. 2024-06-26 13:39:28 -04:00
Alex Hart
b55ba67b66 Split out backup and subscription error sheet handling. 2024-06-26 13:39:28 -04:00
Michelle Tang
37a2d5fbca Release updated linked devices. 2024-06-26 13:39:28 -04:00
Nicholas Tinsley
d7b5c6bff3 Delete registration V1. 2024-06-26 13:39:28 -04:00
Alex Hart
f11028529e Fix hard coded placeholder in iDEAL dialog. 2024-06-25 10:19:27 -03:00
Greyson Parrelli
93ec322bb9 Bump version to 7.10.0 2024-06-24 15:37:57 -04:00
Greyson Parrelli
71e0468d2c Update translations and other static files. 2024-06-24 15:37:08 -04:00
moiseev-signal
816e3442a0 Adopt libsignal 0.51.1 2024-06-24 15:04:03 -04:00
Cody Henthorne
c37ed722dc Attempt to fix potential draft setting loop. 2024-06-24 15:04:03 -04:00
Michelle Tang
e08c2966c3 Move biometrics check when linking a device. 2024-06-24 15:04:03 -04:00
Cody Henthorne
976f80ff7e Fix conversation not closing after delete bug. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
34a4bda331 Do not send PNI-hello-worlds for new installs. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
a4077ccb4a Add app bundle support.
Co-authored-by: Joshua Lund <josh@signal.org>
2024-06-24 15:04:03 -04:00
Cody Henthorne
21ada2a503 Fix request to rejoin after group updates bug. 2024-06-24 15:04:03 -04:00
Alex Hart
57a70c3085 Quiet down auth check job. 2024-06-24 15:04:03 -04:00
Cody Henthorne
16c8b88f0f Fix multiple text attachments when forwarding to multiple recipients.
Fixes #13593
2024-06-24 15:04:03 -04:00
Michelle Tang
b806952430 Add device-specific support configs. 2024-06-24 15:04:03 -04:00
Alex Hart
c0da0bd272 Add proper payment method type to BackupTypeSettings screen. 2024-06-24 15:04:03 -04:00
Alex Hart
45239c2264 Add payment history screens for backups. 2024-06-24 15:04:03 -04:00
Alex Hart
690236c4e5 Handle manual cancellation UI hint in DonationValues. 2024-06-24 15:04:03 -04:00
Clark
ebee3f72e6 Add test infrastructure for backup binprotos. 2024-06-24 15:04:03 -04:00
Alex Hart
37cec7d44f Implement 1:1 call event delete syncs. 2024-06-24 15:04:03 -04:00
Alex Hart
187fd63a75 Add content for disk full alert for backups. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
362cdfc463 Use a snapshot of the SignalStore during backups. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
863b443317 Convert SignalStore to kotlin. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
341c474610 Remove some indirect database reads from backup export. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
cbb3c0911c Create backups from copies of the database file.
Still more work here to do with regards to certain tables, like
SignalStore and Recipient.
2024-06-24 15:04:03 -04:00
Greyson Parrelli
890facc6f6 Clean up ChatItemExportIterator. 2024-06-24 15:04:03 -04:00
Greyson Parrelli
6fa8337058 Update to the latest backup v2 spec.
Removes some dead protos, removes some sticker details, adds in gift
badges.
2024-06-24 15:04:03 -04:00
Alex Hart
3f1cb65e02 Migrate translatable strings from fragment to xml. 2024-06-24 15:04:03 -04:00
Cody Henthorne
3551e7ec00 Remove rx send remote config and group send using rx always. 2024-06-24 15:04:03 -04:00
Alex Hart
5ecf60a306 Add ability to turn off and delete backups. 2024-06-24 15:04:03 -04:00
Cody Henthorne
6659700a1c Improve delete sync coverage for partial expiring threads. 2024-06-24 15:04:02 -04:00
Cody Henthorne
070174fee6 Add delete sync capability to log section. 2024-06-24 15:04:02 -04:00
Cody Henthorne
09003d85b1 Add single attachment delete sync. 2024-06-24 15:04:02 -04:00
Alex Hart
ea87108def Heal InAppPaymentSubscriber currency if we have a payment with a matching subscriber id. 2024-06-24 15:04:02 -04:00
Nicholas Tinsley
7a696f9a62 Increase pluralization for raised hand snackbar. 2024-06-24 15:04:02 -04:00
Nicholas Tinsley
8ba57a2733 Upgrade OkHttp to 4.12.
Addresses #13491
2024-06-24 15:04:02 -04:00
Cody Henthorne
9824cc2cbe Update delete sync strings. 2024-06-24 15:04:02 -04:00
Michelle Tang
ad60cc72cb Add empty linked device state. 2024-06-24 15:04:02 -04:00
Nicholas Tinsley
1950b80402 Update AndroidX Camera libraries to 1.3.4. 2024-06-24 15:04:02 -04:00
Nicholas Tinsley
2acb47952b Differently pluralize raise hand strings. 2024-06-24 15:04:02 -04:00
Cody Henthorne
14cacaef86 Add verbose tracking to DelteForMeSync test to help finding flakey test. 2024-06-24 15:04:02 -04:00
Michelle Tang
958e815933 Remove ability to scan qr code from gallery. 2024-06-24 15:04:02 -04:00
Alex Hart
6b50be78c0 Implement start of backups payment integration work. 2024-06-24 15:04:02 -04:00
Michelle Tang
680223c4b6 Update permission buttons for contacts.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2024-06-24 15:04:02 -04:00
Greyson Parrelli
1af914d5ef Use SubsamplingImageView for everything except GIFs.
Fixes #10324
2024-06-24 15:04:02 -04:00
Cody Henthorne
a2fc710261 Add support for addressing attachments within a message. 2024-06-24 15:04:02 -04:00
Dan Brunwasser
10922594b3 Improve system emoji rendering across the app with EmojiCompat2.
Resolves #13327
2024-06-24 15:04:02 -04:00
Michelle Tang
abd80c5204 Update linked devices UI. 2024-06-24 15:04:02 -04:00
Alex Hart
ff589e3b91 Fix call link export crash. 2024-06-24 15:04:02 -04:00
Cody Henthorne
c80ccd70ec Add additional delete sync support. 2024-06-24 15:04:02 -04:00
Jim Gustafson
d22d18da47 Update to RingRTC v2.44.0 2024-06-24 15:04:02 -04:00
Clark
75b41c34ea Add import/export for stickers and sticker packs. 2024-06-24 15:04:02 -04:00
Alex Hart
11557e4815 Rewrite fallbackphoto system. 2024-06-24 15:04:02 -04:00
Greyson Parrelli
d698f74d0b Rename FeatureFlags -> RemoteConfig. 2024-06-24 15:04:02 -04:00
Greyson Parrelli
ecbea9fd95 Improve FeatureFlag change detection, use for SVR3. 2024-06-24 15:04:02 -04:00
Greyson Parrelli
13f7a64139 Refactor FeatureFlags. 2024-06-24 15:04:02 -04:00
Michelle Tang
39cb1c638e Remove sms tag from contacts. 2024-06-24 15:04:02 -04:00
Nicholas Tinsley
489b58ad67 Abort transcoding if frame processing gets stuck. 2024-06-24 15:04:02 -04:00
Cody Henthorne
f20fe33af9 Ignore flakey delete sync test. 2024-06-24 15:04:02 -04:00
Clark
6adddf4a0c Add display of last backup time to restore flow. 2024-06-24 15:04:01 -04:00
Clark Chen
16773c9b17 Fix import/export tests with my story. 2024-06-24 15:04:01 -04:00
Michelle Tang
4b50365fa9 Release updated linked devices behind feature flag. 2024-06-24 15:04:01 -04:00
Clark Chen
98766b9ebb Fix backup type dark mode. 2024-06-24 15:04:01 -04:00
Cody Henthorne
45a739ce92 Show notification for group adds. 2024-06-24 15:04:01 -04:00
Clark
c0d7145ada Add handling for "My Story" import/export. 2024-06-24 15:04:01 -04:00
Clark
f94c007af8 Make message backup settings screen update properly. 2024-06-24 15:04:01 -04:00
Michelle Tang
df19cb5795 Increase minimum height of keyboard. 2024-06-24 15:04:01 -04:00
Nicholas Tinsley
e6ceb55092 Match account deletion number by short NSN.
Fixes #13583.
2024-06-24 15:04:01 -04:00
Michelle Tang
bfe2b5cba9 Add loading screen to linked devices. 2024-06-21 09:19:47 -03:00
Alex Hart
571004df50 Tokenize group title search. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
f32b59f0aa Fix crash with AnalyzeDatabaseJob. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
e4019d8595 Ignore deprecated backup tests. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
0b66a8701e Convert FeatureFlags to kotlin. 2024-06-21 09:19:47 -03:00
Clark
e62b8de1bc Fix most of import/export integration tests. 2024-06-21 09:19:47 -03:00
Michelle Tang
d5cd790871 Remove redundant gallery permission ask. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
664c22d8f1 Add mostly-working SVR3 implementation behind flag. 2024-06-21 09:19:47 -03:00
Cody Henthorne
143a61e312 Fix calling error state display bugs. 2024-06-21 09:19:47 -03:00
Michelle Tang
baaad0e475 Fix camera-first qr scans. 2024-06-21 09:19:47 -03:00
Michelle Tang
7086709082 Update devices screen after linking a new device. 2024-06-21 09:19:47 -03:00
Michelle Tang
7bd5ad8c0b Use common compose qr screen for usernames. 2024-06-21 09:19:47 -03:00
Michelle Tang
df19c91ae2 Add padding to quoted messages. 2024-06-21 09:19:47 -03:00
Clark
e5872037e0 Add import/export of chat colors. 2024-06-21 09:19:47 -03:00
Clark
b782fabbb6 Update backup proto with subscriber and recipient changes. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
485b466bd2 Crash on RuntimeExceptions thrown during all Jobs. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
3beac6dfa9 Fix linked device inactive filtering. 2024-06-21 09:19:47 -03:00
Michelle Tang
98290a9fa3 Update max limit string. 2024-06-21 09:19:47 -03:00
Michelle Tang
13dd59f226 Skip biometrics check if unavailable when linking a device. 2024-06-21 09:19:47 -03:00
Michelle Tang
d9c42a4135 Add ability to scan linked device qr code from gallery. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
644b93e5a3 Provide default text background color. 2024-06-21 09:19:47 -03:00
Greyson Parrelli
3ff218f9c6 Make build deprecation more resilient to clock skew. 2024-06-21 09:19:47 -03:00
Alex Hart
f572eb5322 Add CallLink Observed event and handling. 2024-06-21 09:19:47 -03:00
Michelle Tang
d3eb480d31 Update add linked devices screen. 2024-06-21 09:19:47 -03:00
Michelle Tang
ac52b5b992 Update linked devices screen. 2024-06-21 09:19:47 -03:00
Michelle Tang
5c181e774f Prevent editing on stickers. 2024-06-21 09:19:46 -03:00
Michelle Tang
05d25718da Add animation when editing a message. 2024-06-21 09:19:46 -03:00
Clark
66c50bef44 Hook up message backup restore flow to reg v2.
Co-authored-by: Nicholas Tinsley <nicholas@signal.org>
2024-06-21 09:19:46 -03:00
Alex Hart
26bd59c378 Bump version to 7.9.6 2024-06-20 18:13:53 -03:00
Alex Hart
e90eae6080 Update baseline profile. 2024-06-20 18:08:30 -03:00
Alex Hart
86a7db7653 Update translations and other static files. 2024-06-20 18:05:24 -03:00
Cody Henthorne
230de7e9dc Use URL for S3.
Thanks to Oscar Mira <valldrac@molly.im> for bringing this to our attention.
2024-06-20 12:15:49 -04:00
Greyson Parrelli
4b8546a151 Bump version to 7.9.5 2024-06-14 15:16:17 -04:00
Greyson Parrelli
ecd214b91b Update translations and other static files. 2024-06-14 15:15:35 -04:00
Greyson Parrelli
6b5de6e3e5 Only do local donation cancel if it's currently active. 2024-06-14 15:06:13 -04:00
Greyson Parrelli
58b6e49aae Fix NPE when canceling a donation. 2024-06-14 15:06:13 -04:00
Greyson Parrelli
c480512600 Bump version to 7.9.4 2024-06-13 18:34:38 -04:00
Greyson Parrelli
3a5b6476aa Updated language translations. 2024-06-13 18:32:18 -04:00
Alex Hart
cb171092cf Fix crash loop when writing invalid currency . 2024-06-13 18:03:20 -03:00
Nicholas Tinsley
71979b34db Alert user to file system errors during backup restore. 2024-06-13 16:13:43 -04:00
Nicholas Tinsley
73142cea39 Don't hold lazy reference to view binding in delayed runnable. 2024-06-13 15:43:51 -04:00
Nicholas Tinsley
2ab2c6f039 Ensure that substrings match in the registration contact support bottom sheet. 2024-06-13 15:36:41 -04:00
Greyson Parrelli
0bea15c0af Bump version to 7.9.3 2024-06-12 11:23:20 -04:00
Greyson Parrelli
cbd78d78ba Update translations and other static files. 2024-06-12 11:22:44 -04:00
Cody Henthorne
ac0604a753 Fix rare remote megaphone crash. 2024-06-12 11:10:59 -04:00
Nicholas Tinsley
0e57335be1 Split Raise Hand plurals into separate strings. 2024-06-11 11:43:58 -04:00
Greyson Parrelli
c4e64f6fa3 Bump version to 7.9.2 2024-06-10 16:11:51 -04:00
Greyson Parrelli
bf9716f206 Update translations and other static files. 2024-06-10 16:10:42 -04:00
Cody Henthorne
057ffdbaaf Fix conversation memory leak. 2024-06-10 14:54:02 -04:00
Nicholas Tinsley
65dc0d3f34 Disable verbose logging in media converter. 2024-06-10 14:38:19 -04:00
Clark
173ee95e62 Fix backup jitter and add unit tests. 2024-06-10 14:20:56 -04:00
Nicholas Tinsley
789339afa7 Update Raise Hand string. 2024-06-10 11:01:56 -04:00
Nicholas Tinsley
21b518da7a Don't show volume indicator nor switch camera button until incoming call connects. 2024-06-10 11:01:56 -04:00
Nicholas Tinsley
57b6b8dcf1 Improve Raise Hand behavior when in a call with a linked device. 2024-06-07 13:53:58 -04:00
Cody Henthorne
543a85316e Improve FCM check clock skew handling. 2024-06-07 13:02:44 -04:00
Cody Henthorne
2fedb3a0ee Bump version to 7.9.1 2024-06-07 11:59:02 -04:00
Cody Henthorne
ae450aed67 Update baseline profile. 2024-06-07 11:48:08 -04:00
Cody Henthorne
0abb4727fc Update translations and other static files. 2024-06-07 11:30:34 -04:00
Alex Hart
4bc6eb96ff Fix 3DS waiting-for-auth state when launching external application. 2024-06-07 11:05:09 -04:00
Nicholas Tinsley
e6a126d416 Only submit captcha once in Registration V2. 2024-06-07 11:05:09 -04:00
Nicholas Tinsley
fdf858f379 Prevent crash if linked device also raises their hand. 2024-06-07 11:05:09 -04:00
Nicholas Tinsley
4151d123cd Fix crash in registration v2 country code drop down. 2024-06-07 11:05:09 -04:00
Nicholas Tinsley
c8a9759eba Unify Raise Hand copy. 2024-06-07 11:05:09 -04:00
Cody Henthorne
c59b74627f Improve strings for localization. 2024-06-07 11:05:09 -04:00
Nicholas Tinsley
f2191d2996 Adjust text colors in dark mode in Registration V2. 2024-06-07 11:05:09 -04:00
Cody Henthorne
7dfffbd50b Add missing windows aapt2. 2024-06-06 10:21:22 -04:00
1089 changed files with 49428 additions and 28428 deletions

View File

@@ -21,17 +21,10 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1422
val canonicalVersionName = "7.9.0"
val postFixSize = 100
val abiPostFix: Map<String, Int> = mapOf(
"universal" to 0,
"armeabi-v7a" to 1,
"arm64-v8a" to 2,
"x86" to 3,
"x86_64" to 4
)
val canonicalVersionCode = 1438
val canonicalVersionName = "7.12.1"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
val keystores: Map<String, Properties?> = mapOf("debug" to loadKeystoreProperties("keystore.debug.properties"))
@@ -92,6 +85,8 @@ android {
useLibrary("org.apache.http.legacy")
testBuildType = "instrumentation"
android.bundle.language.enableSplit = false
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
}
@@ -142,7 +137,7 @@ android {
packagingOptions {
resources {
excludes += setOf("LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", "META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/LICENSE-notice.md", "META-INF/proguard/androidx-annotations.pro", "libsignal_jni.dylib", "signal_jni.dll")
excludes += setOf("LICENSE.txt", "LICENSE", "NOTICE", "asm-license.txt", "META-INF/LICENSE", "META-INF/LICENSE.md", "META-INF/NOTICE", "META-INF/LICENSE-notice.md", "META-INF/proguard/androidx-annotations.pro", "libsignal_jni.dylib", "signal_jni.dll", "libsignal_jni_testing.dylib", "signal_jni_testing.dll")
}
}
@@ -156,7 +151,7 @@ android {
}
defaultConfig {
versionCode = canonicalVersionCode * postFixSize
versionCode = (canonicalVersionCode * maxHotfixVersions) + currentHotfixVersion
versionName = canonicalVersionName
minSdk = signalMinSdkVersion
@@ -208,6 +203,7 @@ android {
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"")
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.PRODUCTION")
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.INFO")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"unset\"")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"")
@@ -386,6 +382,7 @@ android {
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
buildConfigField("org.signal.libsignal.net.Network.Environment", "LIBSIGNAL_NET_ENV", "org.signal.libsignal.net.Network.Environment.STAGING")
buildConfigField("int", "LIBSIGNAL_LOG_LEVEL", "org.signal.libsignal.protocol.logging.SignalProtocolLogger.DEBUG")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
@@ -405,7 +402,6 @@ android {
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
if (output.baseName.contains("nightly")) {
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
var tag = getCurrentGitTag()
if (!tag.isNullOrEmpty()) {
if (tag.startsWith("v")) {
@@ -419,14 +415,9 @@ android {
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-$versionName.apk")
val abiName: String = output.getFilter("ABI") ?: "universal"
val postFix: Int = abiPostFix[abiName]!!
if (postFix >= postFixSize) {
throw AssertionError("postFix is too large")
if (currentHotfixVersion >= maxHotfixVersions) {
throw AssertionError("Hotfix version is too large!")
}
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
}
@@ -435,6 +426,12 @@ android {
beforeVariants { variant ->
variant.enable = variant.name in selectableVariants
}
onVariants { variant ->
// Include the test-only library on debug builds.
if (variant.buildType != "debug") {
variant.packaging.jniLibs.excludes.add("**/libsignal_jni_testing.so")
}
}
}
val releaseDir = "$projectDir/src/release/java"
@@ -509,6 +506,7 @@ dependencies {
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.asynclayoutinflater)
implementation(libs.androidx.asynclayoutinflater.appcompat)
implementation(libs.androidx.emoji2)
implementation(libs.firebase.messaging) {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
@@ -540,6 +538,7 @@ dependencies {
}
implementation(libs.stream)
implementation(libs.lottie)
implementation(libs.lottie.compose)
implementation(libs.signal.android.database.sqlcipher)
implementation(libs.androidx.sqlite)
implementation(libs.google.ez.vcard) {

View File

@@ -10,6 +10,7 @@ import android.database.Cursor
import androidx.core.content.contentValuesOf
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.junit.Before
import org.junit.Ignore
import org.junit.Test
import org.signal.core.util.Hex
import org.signal.core.util.SqlUtil
@@ -47,6 +48,7 @@ import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.io.ByteArrayInputStream
import java.util.Currency
import java.util.UUID
import kotlin.random.Random
@@ -81,18 +83,20 @@ class BackupTest {
@Before
fun setup() {
SignalStore.account().setE164(SELF_E164)
SignalStore.account().setAci(SELF_ACI)
SignalStore.account().setPni(SELF_PNI)
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
SignalStore.account.setE164(SELF_E164)
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
}
@Ignore("Will likely be removed soon")
@Test
fun emptyDatabase() {
backupTest { }
}
@Ignore("Will likely be removed soon")
@Test
fun noteToSelf() {
backupTest {
@@ -104,6 +108,7 @@ class BackupTest {
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualChat() {
backupTest {
@@ -120,6 +125,7 @@ class BackupTest {
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualRecipients() {
backupTest {
@@ -151,6 +157,7 @@ class BackupTest {
}
}
@Ignore("Will likely be removed soon")
@Test
fun individualCallLogs() {
backupTest {
@@ -233,6 +240,7 @@ class BackupTest {
}
}
@Ignore("Will likely be removed soon")
@Test
fun accountData() {
val context = AppDependencies.application
@@ -243,34 +251,34 @@ class BackupTest {
// TODO note-to-self archived
// TODO note-to-self unread
SignalStore.account().setAci(SELF_ACI)
SignalStore.account().setPni(SELF_PNI)
SignalStore.account().setE164(SELF_E164)
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.setE164(SELF_E164)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
SignalDatabase.recipients.setProfileKey(self.id, ProfileKey(Random.nextBytes(32)))
SignalDatabase.recipients.setProfileName(self.id, ProfileName.fromParts("Peter", "Parker"))
SignalDatabase.recipients.setProfileAvatar(self.id, "https://example.com/")
InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), "USD", InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN))
SignalStore.donationsValues().setDisplayBadgesOnProfile(false)
InAppPaymentsRepository.setSubscriber(InAppPaymentSubscriberRecord(SubscriberId.generate(), Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION, false, InAppPaymentData.PaymentMethodType.UNKNOWN))
SignalStore.inAppPayments.setDisplayBadgesOnProfile(false)
SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
SignalStore.settings().isLinkPreviewsEnabled = false
SignalStore.settings().isPreferSystemContactPhotos = true
SignalStore.settings().universalExpireTimer = 42
SignalStore.settings().setKeepMutedChatsArchived(true)
SignalStore.settings.isLinkPreviewsEnabled = false
SignalStore.settings.isPreferSystemContactPhotos = true
SignalStore.settings.universalExpireTimer = 42
SignalStore.settings.setKeepMutedChatsArchived(true)
SignalStore.storyValues().viewedReceiptsEnabled = false
SignalStore.storyValues().userHasViewedOnboardingStory = true
SignalStore.storyValues().isFeatureDisabled = false
SignalStore.storyValues().userHasBeenNotifiedAboutStories = true
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = true
SignalStore.story.viewedReceiptsEnabled = false
SignalStore.story.userHasViewedOnboardingStory = true
SignalStore.story.isFeatureDisabled = false
SignalStore.story.userHasBeenNotifiedAboutStories = true
SignalStore.story.userHasSeenGroupStoryEducationSheet = true
SignalStore.emojiValues().reactions = listOf("a", "b", "c")
SignalStore.emoji.reactions = listOf("a", "b", "c")
TextSecurePreferences.setTypingIndicatorsEnabled(context, false)
TextSecurePreferences.setReadReceiptsEnabled(context, false)

View File

@@ -5,7 +5,10 @@
package org.thoughtcrime.securesms.backup.v2
import android.Manifest
import android.app.UiAutomation
import android.content.Context
import android.os.Environment
import androidx.test.platform.app.InstrumentationRegistry
import okio.ByteString.Companion.toByteString
import org.junit.Assert
@@ -26,12 +29,17 @@ import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.ContactAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
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.GiftBadge
import org.thoughtcrime.securesms.backup.v2.proto.Group
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LinkPreview
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
@@ -46,9 +54,12 @@ import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
import org.thoughtcrime.securesms.backup.v2.proto.Text
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Util
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
@@ -69,6 +80,14 @@ import kotlin.time.Duration.Companion.days
*/
class ImportExportTest {
companion object {
/**
* Output the frames as a plaintext .binproto for sharing tests
*
* This only seems to work on API 28 emulators, You can find the generated files
* at /sdcard/backup-tests/
* */
val OUTPUT_FILES = false
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val SELF_E164 = "+10000000000"
@@ -77,7 +96,17 @@ class ImportExportTest {
val defaultBackupInfo = BackupInfo(version = 1L, backupTimeMs = 123456L)
val selfRecipient = Recipient(id = 1, self = Self())
val releaseNotes = Recipient(id = 2, releaseNotes = ReleaseNotes())
val myStory = Recipient(
id = 2,
distributionList = DistributionListItem(
distributionId = DistributionId.MY_STORY.asUuid().toByteArray().toByteString(),
distributionList = DistributionList(
name = DistributionId.MY_STORY.toString(),
privacyMode = DistributionList.PrivacyMode.ALL
)
)
)
val releaseNotes = Recipient(id = 3, releaseNotes = ReleaseNotes())
val standardAccountData = AccountData(
profileKey = SELF_PROFILE_KEY.serialize().toByteString(),
username = "self.01",
@@ -85,9 +114,11 @@ class ImportExportTest {
givenName = "Peter",
familyName = "Parker",
avatarUrlPath = "https://example.com/",
subscriberId = SubscriberId.generate().bytes.toByteString(),
subscriberCurrencyCode = "USD",
subscriptionManuallyCancelled = true,
donationSubscriberData = AccountData.SubscriberData(
subscriberId = SubscriberId.generate().bytes.toByteString(),
currencyCode = "USD",
manuallyCancelled = true
),
accountSettings = AccountData.AccountSettings(
readReceipts = true,
sealedSenderIndicators = true,
@@ -109,16 +140,15 @@ class ImportExportTest {
)
)
val alice = Recipient(
id = 3,
id = 4,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -128,9 +158,9 @@ class ImportExportTest {
)
/**
* When using standardFrames you must start recipient ids at 3.
* When using standardFrames you must start recipient ids at 4.
*/
private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, releaseNotes)
private val standardFrames = arrayOf(defaultBackupInfo, standardAccountData, selfRecipient, myStory, releaseNotes)
}
private val context: Context
@@ -142,12 +172,12 @@ class ImportExportTest {
@Before
fun setup() {
SignalStore.svr().setMasterKey(MasterKey(MASTER_KEY), "1234")
SignalStore.account().setE164(SELF_E164)
SignalStore.account().setAci(SELF_ACI)
SignalStore.account().setPni(SELF_PNI)
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
SignalStore.account.setE164(SELF_E164)
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
}
@Test
@@ -159,7 +189,7 @@ class ImportExportTest {
fun largeNumberOfRecipientsAndChats() {
val recipients = ArrayList<Recipient>(5000)
val chats = ArrayList<Chat>(5000)
var id = 3L
var id = 4L
for (i in 0..5000) {
val recipientId = id++
recipients.add(
@@ -171,9 +201,8 @@ class ImportExportTest {
username = "rec$i.01",
e164 = 14125550000 + i,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Test",
@@ -237,9 +266,8 @@ class ImportExportTest {
username = if (random.trueWithProbability(0.2f)) "rec$i.01" else null,
e164 = 14125550000 + i,
blocked = random.trueWithProbability(0.1f),
hidden = random.trueWithProbability(0.1f),
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = if (random.trueWithProbability(0.1f)) Contact.Visibility.HIDDEN else Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = random.trueWithProbability(0.9f),
profileGivenName = "Test",
@@ -383,16 +411,15 @@ class ImportExportTest {
importExport(
*standardFrames,
Recipient(
id = 3,
id = 4,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -401,16 +428,15 @@ class ImportExportTest {
)
),
Recipient(
id = 4,
id = 5,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501235,
blocked = true,
hidden = true,
registered = Contact.Registered.NOT_REGISTERED,
unregisteredTimestamp = 1234568927398L,
visibility = Contact.Visibility.HIDDEN,
notRegistered = Contact.NotRegistered(unregisteredTimestamp = 1234568927398L),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = false,
profileGivenName = "Peter",
@@ -426,7 +452,7 @@ class ImportExportTest {
importExport(
*standardFrames,
Recipient(
id = 3,
id = 4,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
@@ -441,7 +467,7 @@ class ImportExportTest {
)
),
Recipient(
id = 4,
id = 5,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = false,
@@ -463,16 +489,15 @@ class ImportExportTest {
importExport(
*standardFrames,
Recipient(
id = 3,
id = 4,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.HIDDEN,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -480,35 +505,33 @@ class ImportExportTest {
hideStory = true
)
),
Recipient(
id = 4,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501235,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Peter",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 5,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501235,
blocked = true,
visibility = Contact.Visibility.HIDDEN,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Peter",
profileFamilyName = "Kim",
hideStory = true
)
),
Recipient(
id = 6,
contact = Contact(
aci = null,
pni = null,
username = null,
e164 = 141255501236,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.HIDDEN,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Father",
@@ -517,14 +540,15 @@ class ImportExportTest {
)
),
Recipient(
id = 6,
distributionList = DistributionList(
name = "Kim Family",
id = 7,
distributionList = DistributionListItem(
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
allowReplies = true,
deletionTimestamp = 0L,
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
memberRecipientIds = listOf(3, 4, 5)
distributionList = DistributionList(
name = "Kim Family",
allowReplies = true,
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
memberRecipientIds = listOf(3, 4, 5)
)
)
)
)
@@ -533,16 +557,15 @@ class ImportExportTest {
@Test
fun deletedDistributionList() {
val alexa = Recipient(
id = 3,
id = 4,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = true,
hidden = true,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.HIDDEN,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -555,13 +578,9 @@ class ImportExportTest {
alexa,
Recipient(
id = 6,
distributionList = DistributionList(
name = "Deleted list",
distributionList = DistributionListItem(
distributionId = DistributionId.create().asUuid().toByteArray().toByteString(),
allowReplies = true,
deletionTimestamp = 12345L,
privacyMode = DistributionList.PrivacyMode.ONLY_WITH,
memberRecipientIds = listOf(3)
deletionTimestamp = 12345L
)
)
)
@@ -580,16 +599,15 @@ class ImportExportTest {
importExport(
*standardFrames,
Recipient(
id = 3,
id = 4,
contact = Contact(
aci = TestRecipientUtils.nextAci().toByteString(),
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -598,7 +616,7 @@ class ImportExportTest {
)
),
Recipient(
id = 4,
id = 5,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
@@ -608,14 +626,13 @@ class ImportExportTest {
),
Chat(
id = 1,
recipientId = 3,
recipientId = 4,
archived = true,
pinnedOrder = 1,
expirationTimerMs = 1.days.inWholeMilliseconds,
muteUntilMs = System.currentTimeMillis(),
markedUnread = true,
dontNotifyForMentionsIfMuted = true,
wallpaper = null
dontNotifyForMentionsIfMuted = true
)
)
}
@@ -682,16 +699,15 @@ class ImportExportTest {
importExport(
*standardFrames,
Recipient(
id = 3,
id = 4,
contact = Contact(
aci = startedAci,
pni = TestRecipientUtils.nextPni().toByteString(),
username = "cool.01",
e164 = 141255501234,
blocked = false,
hidden = false,
registered = Contact.Registered.REGISTERED,
unregisteredTimestamp = 0L,
visibility = Contact.Visibility.VISIBLE,
registered = Contact.Registered(),
profileKey = TestRecipientUtils.generateProfileKey().toByteString(),
profileSharing = true,
profileGivenName = "Alexa",
@@ -700,7 +716,7 @@ class ImportExportTest {
)
),
Recipient(
id = 4,
id = 5,
group = Group(
masterKey = TestRecipientUtils.generateGroupMasterKey().toByteString(),
whitelisted = true,
@@ -710,14 +726,13 @@ class ImportExportTest {
),
Chat(
id = 1,
recipientId = 3,
recipientId = 4,
archived = true,
pinnedOrder = 1,
expirationTimerMs = 1.days.inWholeMilliseconds,
muteUntilMs = System.currentTimeMillis(),
markedUnread = true,
dontNotifyForMentionsIfMuted = true,
wallpaper = null
dontNotifyForMentionsIfMuted = true
),
*individualCalls.toArray()
)
@@ -1051,23 +1066,175 @@ class ImportExportTest {
incrementalMacChunkSize = 0
),
wasDownloaded = false
)
)
)
)
)
}
@Test
fun linkPreviewMessages() {
var dateSent = System.currentTimeMillis()
val sendStatuses = enumerateSendStatuses(alice.id)
val incomingMessageDetails = enumerateIncomingMessageDetails(dateSent + 200)
val outgoingMessages = ArrayList<ChatItem>()
val incomingMessages = ArrayList<ChatItem>()
for (sendStatus in sendStatuses) {
outgoingMessages.add(
ChatItem(
chatId = 1,
authorId = selfRecipient.id,
dateSent = dateSent++,
expireStartDate = dateSent + 1000,
expiresInMs = TimeUnit.DAYS.toMillis(2),
sms = false,
outgoing = ChatItem.OutgoingMessageDetails(
sendStatus = listOf(sendStatus)
),
standardMessage = StandardMessage(
text = Text(
body = "Text only body"
),
MessageAttachment(
pointer = FilePointer(
backupLocator = FilePointer.BackupLocator(
"digestherebutimlazy",
cdnNumber = 3,
linkPreview = listOf(
LinkPreview(
url = "https://signal.org/",
title = "Signal Messenger: Speak Freely",
description = "Say \"hello\" to a different messaging experience. An unexpected focus on privacy, combined with all the features you expect.",
date = System.currentTimeMillis(),
image = FilePointer(
invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator(),
contentType = "image/png",
width = 100,
height = 200,
caption = "Love this cool picture! Too bad u cant download it",
incrementalMacChunkSize = 0
)
)
)
)
)
)
}
dateSent++
for (incomingDetail in incomingMessageDetails) {
incomingMessages.add(
ChatItem(
chatId = 1,
authorId = alice.id,
dateSent = dateSent++,
expireStartDate = dateSent + 1000,
expiresInMs = TimeUnit.DAYS.toMillis(2),
sms = false,
incoming = incomingDetail,
standardMessage = StandardMessage(
text = Text(
body = "Text only body"
),
linkPreview = listOf(
LinkPreview(
url = "https://signal.org/",
title = "Signal Messenger: Speak Freely",
description = "Say \"hello\" to a different messaging experience. An unexpected focus on privacy, combined with all the features you expect.",
date = System.currentTimeMillis(),
image = FilePointer(
invalidAttachmentLocator = FilePointer.InvalidAttachmentLocator(),
contentType = "image/png",
width = 100,
height = 200,
caption = "Love this cool picture! Too bad u cant download it",
incrementalMacChunkSize = 0
)
)
)
)
)
)
}
importExport(
*standardFrames,
alice,
buildChat(alice, 1),
*outgoingMessages.toArray(),
*incomingMessages.toArray()
)
}
@Test
fun contactMessageWithAllFields() {
importExport(
*standardFrames,
alice,
buildChat(alice, 1),
ChatItem(
chatId = 1,
authorId = selfRecipient.id,
dateSent = 150L,
sms = false,
outgoing = ChatItem.OutgoingMessageDetails(
sendStatus = listOf(SendStatus(alice.id, deliveryStatus = SendStatus.Status.READ, lastStatusUpdateTimestamp = -1))
),
contactMessage = ContactMessage(
contact = listOf(
ContactAttachment(
name = ContactAttachment.Name(
givenName = "Given",
familyName = "Family",
prefix = "Prefix",
suffix = "Suffix",
middleName = "Middle",
displayName = "Display Name"
),
organization = "Organization",
email = listOf(
ContactAttachment.Email(
value_ = "coolemail@gmail.com",
label = "Label",
type = ContactAttachment.Email.Type.HOME
),
ContactAttachment.Email(
value_ = "coolemail2@gmail.com",
label = "Label2",
type = ContactAttachment.Email.Type.MOBILE
)
),
address = listOf(
ContactAttachment.PostalAddress(
type = ContactAttachment.PostalAddress.Type.HOME,
label = "Label",
street = "Street",
pobox = "POBOX",
neighborhood = "Neighborhood",
city = "City",
region = "Region",
postcode = "15213",
country = "United States"
)
),
number = listOf(
ContactAttachment.Phone(
value_ = "+14155551234",
type = ContactAttachment.Phone.Type.CUSTOM,
label = "Label"
)
),
avatar = FilePointer(
attachmentLocator = FilePointer.AttachmentLocator(
cdnKey = "coolCdnKey",
cdnNumber = 2,
uploadTimestamp = System.currentTimeMillis(),
key = (1..32).map { it.toByte() }.toByteArray().toByteString(),
digest = (1..64).map { it.toByte() }.toByteArray().toByteString(),
size = 12345
size = 12345,
digest = (1..32).map { it.toByte() }.toByteArray().toByteString()
),
contentType = "image/png",
fileName = "very_cool_picture.png",
width = 100,
height = 200,
caption = "Love this cool picture! Too bad u cant download it",
caption = "Love this cool picture!",
incrementalMacChunkSize = 0
),
wasDownloaded = true
)
)
)
)
@@ -1256,6 +1423,76 @@ class ImportExportTest {
)
}
@Test
fun giftBadgeMessage() {
var dateSentStart = 100L
importExport(
*standardFrames,
alice,
buildChat(alice, 1),
ChatItem(
chatId = 1,
authorId = alice.id,
dateSent = dateSentStart++,
incoming = ChatItem.IncomingMessageDetails(
dateReceived = dateSentStart,
dateServerSent = dateSentStart,
read = true,
sealedSender = true
),
giftBadge = GiftBadge(
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
state = GiftBadge.State.OPENED
)
),
ChatItem(
chatId = 1,
authorId = alice.id,
dateSent = dateSentStart++,
incoming = ChatItem.IncomingMessageDetails(
dateReceived = dateSentStart,
dateServerSent = dateSentStart,
read = true,
sealedSender = true
),
giftBadge = GiftBadge(
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
state = GiftBadge.State.FAILED
)
),
ChatItem(
chatId = 1,
authorId = alice.id,
dateSent = dateSentStart++,
incoming = ChatItem.IncomingMessageDetails(
dateReceived = dateSentStart,
dateServerSent = dateSentStart,
read = true,
sealedSender = true
),
giftBadge = GiftBadge(
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
state = GiftBadge.State.REDEEMED
)
),
ChatItem(
chatId = 1,
authorId = alice.id,
dateSent = dateSentStart++,
incoming = ChatItem.IncomingMessageDetails(
dateReceived = dateSentStart,
dateServerSent = dateSentStart,
read = true,
sealedSender = true
),
giftBadge = GiftBadge(
receiptCredentialPresentation = Util.getSecretBytes(32).toByteString(),
state = GiftBadge.State.UNOPENED
)
)
)
}
fun enumerateIncomingMessageDetails(dateSent: Long): List<ChatItem.IncomingMessageDetails> {
val details = mutableListOf<ChatItem.IncomingMessageDetails>()
details.add(
@@ -1361,8 +1598,7 @@ class ImportExportTest {
expirationTimerMs = 0,
muteUntilMs = 0,
markedUnread = false,
dontNotifyForMentionsIfMuted = false,
wallpaper = null
dontNotifyForMentionsIfMuted = false
)
}
@@ -1371,27 +1607,17 @@ class ImportExportTest {
* any standard frames (e.g. backup header).
*/
private fun exportFrames(vararg objects: Any): ByteArray {
outputBinProto(*objects)
val outputStream = ByteArrayOutputStream()
val writer = EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account.aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
writer.use {
for (obj in objects) {
when (obj) {
is BackupInfo -> writer.write(obj)
is AccountData -> writer.write(Frame(account = obj))
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is AdHocCall -> writer.write(Frame(adHocCall = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
}
writer.writeFrames(*objects)
}
return outputStream.toByteArray()
}
@@ -1402,7 +1628,7 @@ class ImportExportTest {
private fun validate(importData: ByteArray): MessageBackup.ValidationResult {
val factory = { ByteArrayInputStream(importData) }
val masterKey = SignalStore.svr().getOrCreateMasterKey()
val masterKey = SignalStore.svr.getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), org.signal.libsignal.protocol.ServiceId.Aci.parseFromBinary(SELF_ACI.toByteArray()))
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, factory, importData.size.toLong())
@@ -1417,37 +1643,51 @@ class ImportExportTest {
* 4. Assert that (A) and (B) are identical. Or, in other words, assert that importing and exporting again results in the original backup data.
*/
private fun importExport(vararg objects: Any) {
val outputStream = ByteArrayOutputStream()
val writer = EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
val originalBackupData = exportFrames(*objects)
writer.use {
for (obj in objects) {
when (obj) {
is BackupInfo -> writer.write(obj)
is AccountData -> writer.write(Frame(account = obj))
is Recipient -> writer.write(Frame(recipient = obj))
is Chat -> writer.write(Frame(chat = obj))
is ChatItem -> writer.write(Frame(chatItem = obj))
is AdHocCall -> writer.write(Frame(adHocCall = obj))
is StickerPack -> writer.write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
}
}
val originalBackupData = outputStream.toByteArray()
BackupRepository.import(length = originalBackupData.size.toLong(), inputStreamFactory = { ByteArrayInputStream(originalBackupData) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
import(originalBackupData)
val generatedBackupData = BackupRepository.export()
compare(originalBackupData, generatedBackupData)
}
private fun BackupExportWriter.writeFrames(vararg objects: Any) {
for (obj in objects) {
when (obj) {
is BackupInfo -> write(obj)
is AccountData -> write(Frame(account = obj))
is Recipient -> write(Frame(recipient = obj))
is Chat -> write(Frame(chat = obj))
is ChatItem -> write(Frame(chatItem = obj))
is AdHocCall -> write(Frame(adHocCall = obj))
is StickerPack -> write(Frame(stickerPack = obj))
else -> Assert.fail("invalid object $obj")
}
}
}
private fun outputBinProto(vararg objects: Any) {
if (!OUTPUT_FILES) return
val outputStream = ByteArrayOutputStream()
val plaintextWriter = PlainTextBackupWriter(
outputStream = outputStream
)
plaintextWriter.use {
it.writeFrames(*objects)
}
grantPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE)
val dir = File(Environment.getExternalStorageDirectory(), "backup-tests")
if (dir.mkdirs() || dir.exists()) {
FileOutputStream(File(dir, testName.methodName + ".binproto")).use {
it.write(outputStream.toByteArray())
it.flush()
}
}
}
private fun compare(import: ByteArray, export: ByteArray) {
val selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY)
val framesImported = readAllFrames(import, selfData)
@@ -1484,7 +1724,14 @@ class ImportExportTest {
for (f in framesExported) {
when {
f.account != null -> accountImported.add(f.account!!)
f.recipient != null -> recipientsExported.add(f.recipient!!)
f.recipient != null -> {
val frameRecipient = f.recipient!!
if (frameRecipient.distributionList != null && frameRecipient.distributionList!!.distributionId == DistributionId.MY_STORY.asUuid().toByteArray().toByteString()) {
recipientsExported.add(frameRecipient.copy(distributionList = frameRecipient.distributionList!!.copyWithoutMembers()))
} else {
recipientsExported.add(f.recipient!!)
}
}
f.chat != null -> chatsExported.add(f.chat!!)
f.chatItem != null -> chatItemsExported.add(f.chatItem!!)
f.adHocCall != null -> callsExported.add(f.adHocCall!!)
@@ -1499,6 +1746,14 @@ class ImportExportTest {
prettyAssertEquals(stickersImported, stickersExported) { it.packId }
}
private fun DistributionListItem.copyWithoutMembers(): DistributionListItem {
return this.copy(
distributionList = this.distributionList?.copy(
memberRecipientIds = emptyList()
)
)
}
private inline fun <reified T : Any> prettyAssertEquals(import: List<T>, export: List<T>) {
Assert.assertEquals(import.size, export.size)
import.zip(export).forEach { (a1, a2) ->
@@ -1514,17 +1769,29 @@ class ImportExportTest {
private inline fun <reified T : Any, R : Comparable<R>> prettyAssertEquals(import: List<T>, export: List<T>, crossinline selector: (T) -> R?) {
if (import.size != export.size) {
var msg = StringBuilder()
val msg = StringBuilder()
msg.append("There's a different number of items in the lists!\n\n")
msg.append("Imported:\n")
for (i in import) {
msg.append(i)
msg.append("\n")
}
if (import.isEmpty()) {
msg.append("<None>")
}
msg.append("\n")
msg.append("Exported:\n")
for (i in export) {
msg.append(i)
msg.append("\n")
}
if (export.isEmpty()) {
msg.append("<None>")
}
Assert.fail(msg.toString())
}
Assert.assertEquals(import.size, export.size)
val sortedImport = import.sortedBy(selector)
val sortedExport = export.sortedBy(selector)
@@ -1535,9 +1802,9 @@ class ImportExportTest {
private fun readAllFrames(import: ByteArray, selfData: BackupRepository.SelfData): List<Frame> {
val inputFactory = { ByteArrayInputStream(import) }
val frameReader = EncryptedBackupReader(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
aci = selfData.aci,
streamLength = import.size.toLong(),
length = import.size.toLong(),
dataStream = inputFactory
)
val frames = ArrayList<Frame>()
@@ -1548,20 +1815,12 @@ class ImportExportTest {
return frames
}
private fun writeToOutputFile(importBytes: ByteArray, resultBytes: ByteArray? = null) {
val dir = File(context.filesDir, "backup-tests")
if (dir.mkdirs() || dir.exists()) {
FileOutputStream(File(dir, testName.methodName + ".import")).use {
it.write(importBytes)
it.flush()
}
private fun grantPermissions(vararg permissions: String?) {
if (!OUTPUT_FILES) return
if (resultBytes != null) {
FileOutputStream(File(dir, testName.methodName + ".result")).use {
it.write(resultBytes)
it.flush()
}
}
val auto: UiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
for (perm in permissions) {
auto.grantRuntimePermissionAsUser(InstrumentationRegistry.getInstrumentation().targetContext.packageName, perm, android.os.Process.myUserHandle())
}
}
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import org.signal.core.util.Base64
import org.signal.core.util.StreamUtil
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import java.io.ByteArrayInputStream
import java.util.UUID
import kotlin.random.Random
@RunWith(Parameterized::class)
class ImportExportTestSuite(private val path: String) {
companion object {
val SELF_ACI = ServiceId.ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val SELF_PNI = ServiceId.PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val SELF_E164 = "+10000000000"
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
val MASTER_KEY = Base64.decode("sHuBMP4ToZk4tcNU+S8eBUeCt8Am5EZnvuqTBJIR4Do")
const val TESTS_FOLDER = "backupTests"
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun data(): Collection<Array<String>> {
val testFiles = InstrumentationRegistry.getInstrumentation().context.resources.assets.list(TESTS_FOLDER)
return testFiles?.map { arrayOf(it) }!!.toList()
}
}
@Before
fun setup() {
SignalStore.svr.setMasterKey(MasterKey(MASTER_KEY), "1234")
SignalStore.account.setE164(SELF_E164)
SignalStore.account.setAci(SELF_ACI)
SignalStore.account.setPni(SELF_PNI)
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
}
@Test
fun testBinProto() {
val binProtoBytes: ByteArray = InstrumentationRegistry.getInstrumentation().context.resources.assets.open("${TESTS_FOLDER}/$path").use {
StreamUtil.readFully(it)
}
import(binProtoBytes)
val generatedBackupData = BackupRepository.export()
compare(binProtoBytes, generatedBackupData)
}
private fun import(importData: ByteArray) {
BackupRepository.import(
length = importData.size.toLong(),
inputStreamFactory = { ByteArrayInputStream(importData) },
selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY),
plaintext = true
)
}
// TODO compare with libsignal's library
private fun compare(import: ByteArray, export: ByteArray) {
}
}

View File

@@ -1,408 +0,0 @@
package org.thoughtcrime.securesms.components.settings.app.changenumber
import androidx.lifecycle.SavedStateHandle
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.FlakyTest
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkObject
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
import org.thoughtcrime.securesms.registration.VerifyResponseProcessor
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.MockProvider
import org.thoughtcrime.securesms.testing.Post
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.testing.assertIsNot
import org.thoughtcrime.securesms.testing.assertIsNotNull
import org.thoughtcrime.securesms.testing.assertIsNull
import org.thoughtcrime.securesms.testing.assertIsSize
import org.thoughtcrime.securesms.testing.connectionFailure
import org.thoughtcrime.securesms.testing.failure
import org.thoughtcrime.securesms.testing.parsedRequestBody
import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.testing.timeout
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.MismatchedDevices
import org.whispersystems.signalservice.internal.push.PreKeyState
import java.security.SecureRandom
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ChangeNumberViewModelTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var viewModel: ChangeNumberViewModel
@Before
fun setUp() {
ThreadUtil.runOnMainSync {
viewModel = ChangeNumberViewModel(
localNumber = harness.self.requireE164(),
changeNumberRepository = ChangeNumberRepository(),
savedState = SavedStateHandle(),
password = SignalStore.account().servicePassword!!,
verifyAccountRepository = VerifyAccountRepository(harness.application)
)
viewModel.setNewCountry(1)
viewModel.setNewNationalNumber("5555550102")
}
mockkObject(SvrRepository)
}
@After
fun tearDown() {
unmockkObject(SvrRepository)
InstrumentationApplicationDependencyProvider.clearHandlers()
}
@Test
fun testChangeNumber_givenOnlyPrimaryAndNoRegLock() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
},
Put("/v2/keys") { r ->
setPreKeysRequest = r.parsedRequestBody()
MockResponse().success()
},
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
// THEN
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
}
/**
* If we encounter a server error, this means the server ack our request and rejected it. In this
* case we know the change *did not* take on the server and can reset to a clean state.
*/
@Test
fun testChangeNumber_givenServerFailedApiCall() {
// GIVEN
val oldPni = Recipient.self().requirePni()
val oldE164 = Recipient.self().requireE164()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { MockResponse().failure(500) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
processor.isServerSentError() assertIs true
Recipient.self().requireE164() assertIs oldE164
Recipient.self().requirePni() assertIs oldPni
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
}
/**
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
* number on the server side. We have to do a whoami call to query the server for our details and then
* respond accordingly.
*
* In this case, the whoami is our old details, so we can know the change *did not* take on the server
* and can reset to a clean state.
*/
@Test
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToServer() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val oldPni = Recipient.self().requirePni()
val oldE164 = Recipient.self().requireE164()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { MockResponse().connectionFailure() },
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, oldPni, oldE164)) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
processor.isServerSentError() assertIs false
Recipient.self().requireE164() assertIs oldE164
Recipient.self().requirePni() assertIs oldPni
SignalStore.misc().isChangeNumberLocked assertIs false
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
}
/**
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
* number on the server side. We have to do a whoami call to query the server for our details and then
* respond accordingly.
*
* In this case, the whoami is our new details, so we can know the change *did* take on the server
* and need to keep the app in a locked state. The test then uses the ChangeNumberLockActivity to unlock
* and apply the pending state after confirming the change on the server.
*/
@Test
@FlakyTest
@Ignore("Test sometimes requires manual intervention to continue.")
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToClient() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val oldPni = Recipient.self().requirePni()
val oldE164 = Recipient.self().requireE164()
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
MockResponse().timeout()
},
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, newPni, "+15555550102")) },
Put("/v2/keys") { r ->
setPreKeysRequest = r.parsedRequestBody()
MockResponse().success()
},
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
val processor: VerifyResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
// THEN
processor.isServerSentError() assertIs false
Recipient.self().requireE164() assertIs oldE164
Recipient.self().requirePni() assertIs oldPni
SignalStore.misc().isChangeNumberLocked assertIs true
SignalStore.misc().pendingChangeNumberMetadata.assertIsNotNull()
// WHEN AGAIN Processing lock
val scenario = harness.launchActivity<ChangeNumberLockActivity>()
scenario.onActivity {}
ThreadUtil.sleep(500)
// THEN AGAIN
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
}
@Test
fun testChangeNumber_givenOnlyPrimaryAndRegistrationLock() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
MockResponse().failure(423, MockProvider.lockedFailure)
} else {
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
}
},
Put("/v2/keys") { r ->
setPreKeysRequest = r.parsedRequestBody()
MockResponse().success()
},
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
)
every { SvrRepository.restoreMasterKeyPreRegistration(any(), any(), any()) } returns SecureValueRecovery.RestoreResponse.Success(MasterKey.createNew(SecureRandom()), AuthCredentials.create("username", "password"))
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
processor.registrationLock() assertIs true
Recipient.self().requirePni() assertIsNot newPni
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
}
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
// THEN
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
}
@Test
fun testChangeNumber_givenMismatchedDevicesOnFirstCall() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.deviceMessages.isEmpty()) {
MockResponse().failure(
409,
MismatchedDevices().apply {
missingDevices = listOf(2)
extraDevices = emptyList()
}
)
} else {
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
}
},
Get("/v2/keys/$aci/2") {
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
},
Put("/v2/keys") { r ->
setPreKeysRequest = r.parsedRequestBody()
MockResponse().success()
},
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
)
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
// THEN
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
}
@Test
fun testChangeNumber_givenRegLockAndMismatchedDevicesOnFirstTwoCalls() {
// GIVEN
val aci = Recipient.self().requireServiceId()
val newPni = PNI.from(UUID.randomUUID())
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
Put("/v2/accounts/number") { r ->
changeNumberRequest = r.parsedRequestBody()
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
MockResponse().failure(423, MockProvider.lockedFailure)
} else if (changeNumberRequest.deviceMessages.isEmpty()) {
MockResponse().failure(
409,
MismatchedDevices().apply {
missingDevices = listOf(2)
extraDevices = emptyList()
}
)
} else if (changeNumberRequest.deviceMessages.size == 1) {
MockResponse().failure(
409,
MismatchedDevices().apply {
missingDevices = listOf(2, 3)
extraDevices = emptyList()
}
)
} else {
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
}
},
Get("/v2/keys/$aci/2") {
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
},
Get("/v2/keys/$aci/3") {
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 3))
},
Put("/v2/keys") { r ->
setPreKeysRequest = r.parsedRequestBody()
MockResponse().success()
},
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
)
every { SvrRepository.restoreMasterKeyPreRegistration(any(), any(), any()) } returns SecureValueRecovery.RestoreResponse.Success(MasterKey.createNew(SecureRandom()), AuthCredentials.create("username", "password"))
// WHEN
viewModel.requestVerificationCode(VerifyAccountRepository.Mode.SMS_WITHOUT_LISTENER, null, null).blockingGet().resultOrThrow
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
processor.registrationLock() assertIs true
Recipient.self().requirePni() assertIsNot newPni
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
}
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
// THEN
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
}
private fun assertSuccess(newPni: ServiceId, changeNumberRequest: ChangePhoneNumberRequest, setPreKeysRequest: PreKeyState) {
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account().pniPreKeys
Recipient.self().requireE164() assertIs "+15555550102"
Recipient.self().requirePni() assertIs newPni
SignalStore.account().pniRegistrationId assertIs changeNumberRequest.pniRegistrationIds["1"]!!
SignalStore.account().pniIdentityKey.publicKey assertIs changeNumberRequest.pniIdentityKey
pniMetadataStore.activeSignedPreKeyId assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.keyId
val activeSignedPreKey: SignedPreKeyRecord = pniProtocolStore.loadSignedPreKey(pniMetadataStore.activeSignedPreKeyId)
activeSignedPreKey.keyPair.publicKey assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.publicKey
activeSignedPreKey.signature assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.signature
setPreKeysRequest.signedPreKey.publicKey assertIs activeSignedPreKey.keyPair.publicKey
setPreKeysRequest.preKeys assertIsSize 100
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
}
}

View File

@@ -154,7 +154,8 @@ class ConversationItemPreviewer {
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
System.currentTimeMillis(),
null
)
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2.items
import android.net.Uri
import android.view.View
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import com.bumptech.glide.RequestManager
import io.mockk.mockk
@@ -203,8 +204,8 @@ class V2ConversationItemShapeTest {
private val colorizer = Colorizer()
override val lifecycleOwner: LifecycleOwner = mockk(relaxed = true)
override val displayMode: ConversationItemDisplayMode = ConversationItemDisplayMode.Standard
override val clickListener: ConversationAdapter.ItemClickListener = FakeConversationItemClickListener
override val selectedItems: Set<MultiselectPart> = emptySet()
override val isMessageRequestAccepted: Boolean = true
@@ -289,6 +290,8 @@ class V2ConversationItemShapeTest {
override fun onChangeNumberUpdateContact(recipient: Recipient) = Unit
override fun onChangeProfileNameUpdateContact(recipient: Recipient) = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit

View File

@@ -50,9 +50,9 @@ class AttachmentTableTest_deduping {
@Before
fun setUp() {
SignalStore.account().setAci(ServiceId.ACI.from(UUID.randomUUID()))
SignalStore.account().setPni(ServiceId.PNI.from(UUID.randomUUID()))
SignalStore.account().setE164("+15558675309")
SignalStore.account.setAci(ServiceId.ACI.from(UUID.randomUUID()))
SignalStore.account.setPni(ServiceId.PNI.from(UUID.randomUUID()))
SignalStore.account.setE164("+15558675309")
SignalDatabase.attachments.deleteAllAttachments()
}
@@ -824,7 +824,8 @@ class AttachmentTableTest_deduping {
uploadTimestamp,
databaseAttachment.caption,
databaseAttachment.stickerLocator,
databaseAttachment.blurHash
databaseAttachment.blurHash,
databaseAttachment.uuid
)
}
}

View File

@@ -56,7 +56,7 @@ class CallTableTest {
)
val call = SignalDatabase.calls.getCallById(callId, groupRecipientId)
SignalDatabase.calls.deleteGroupCall(call!!)
SignalDatabase.calls.markCallDeletedFromSyncEvent(call!!)
val deletedCall = SignalDatabase.calls.getCallById(callId, groupRecipientId)
val oldestDeletionTimestamp = SignalDatabase.calls.getOldestDeletionTimestamp()
@@ -69,9 +69,10 @@ class CallTableTest {
@Test
fun givenNoPreExistingEvent_whenIDeleteGroupCall_thenIInsertAndMarkCallDeleted() {
val callId = 1L
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
SignalDatabase.calls.insertDeletedCallFromSyncEvent(
callId,
groupRecipientId,
CallTable.Type.GROUP_CALL,
CallTable.Direction.OUTGOING,
System.currentTimeMillis()
)
@@ -438,11 +439,12 @@ class CallTableTest {
@Test
fun givenADeletedCallEvent_whenIReceiveARingUpdate_thenIIgnoreTheRingUpdate() {
val callId = 1L
SignalDatabase.calls.insertDeletedGroupCallFromSyncEvent(
SignalDatabase.calls.insertDeletedCallFromSyncEvent(
callId = callId,
recipientId = groupRecipientId,
direction = CallTable.Direction.INCOMING,
timestamp = System.currentTimeMillis()
timestamp = System.currentTimeMillis(),
type = CallTable.Type.GROUP_CALL
)
SignalDatabase.calls.insertOrUpdateGroupCallFromRingState(

View File

@@ -167,8 +167,8 @@ class GroupTableTest {
@Test
fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() {
val g1 = insertPushGroup(listOf())
val g2 = insertPushGroup(listOf())
val g1 = insertPushGroup(members = emptyList())
val g2 = insertPushGroup(members = emptyList())
val gr1 = groupTable.getGroup(g1)
val gr2 = groupTable.getGroup(g2)
@@ -195,6 +195,85 @@ class GroupTableTest {
assertEquals(groups[0].id, groupInCommon)
}
@Test
fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForTheSharedToken_thenIExpectBothGroups() {
insertPushGroup("Group Alice")
insertPushGroup("Group Bob")
SignalDatabase.groups.queryGroupsByTitle(
inputQuery = "Group",
includeInactive = false,
excludeV1 = false,
excludeMms = false
).use {
assertEquals(2, it.cursor?.count)
val firstGroup = it.getNext()
val secondGroup = it.getNext()
assertEquals("Group Alice", firstGroup?.title)
assertEquals("Group Bob", secondGroup?.title)
}
}
@Test
fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForAnUnsharedToken_thenIExpectOneGroup() {
insertPushGroup("Group Alice")
insertPushGroup("Group Bob")
SignalDatabase.groups.queryGroupsByTitle(
inputQuery = "Alice",
includeInactive = false,
excludeV1 = false,
excludeMms = false
).use {
assertEquals(1, it.cursor?.count)
val firstGroup = it.getNext()
assertEquals("Group Alice", firstGroup?.title)
}
}
@Test
fun givenAGroupWithThreeTokens_whenISearchForTheFirstAndLastToken_thenIExpectThatGroup() {
insertPushGroup("Group & Alice")
SignalDatabase.groups.queryGroupsByTitle(
inputQuery = "Group Alice",
includeInactive = false,
excludeV1 = false,
excludeMms = false
).use {
assertEquals(1, it.cursor?.count)
val firstGroup = it.getNext()
assertEquals("Group & Alice", firstGroup?.title)
}
}
@Test
fun givenTwoGroupsWithSharedTokens_whenISearchForAnExactMatch_thenIExpectThatGroupFirst() {
insertPushGroup("Group Alice Bob")
insertPushGroup("Group Bob")
SignalDatabase.groups.queryGroupsByTitle(
inputQuery = "Group Bob",
includeInactive = false,
excludeV1 = false,
excludeMms = false
).use {
assertEquals(2, it.cursor?.count)
val firstGroup = it.getNext()
val second = it.getNext()
assertEquals("Group Bob", firstGroup?.title)
assertEquals("Group Alice Bob", second?.title)
}
}
private fun insertThread(groupId: GroupId): Long {
val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get()
return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient))
@@ -214,6 +293,7 @@ class GroupTableTest {
}
private fun insertPushGroup(
title: String = "Test Group",
members: List<DecryptedMember> = listOf(
DecryptedMember.Builder()
.aciBytes(harness.self.requireAci().toByteString())
@@ -229,11 +309,12 @@ class GroupTableTest {
): GroupId {
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
val decryptedGroupState = DecryptedGroup.Builder()
.title(title)
.members(members)
.revision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!
return groupTable.create(groupMasterKey, decryptedGroupState, null)!!
}
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
@@ -258,6 +339,6 @@ class GroupTableTest {
.revision(0)
.build()
return groupTable.create(groupMasterKey, decryptedGroupState)!!
return groupTable.create(groupMasterKey, decryptedGroupState, null)!!
}
}

View File

@@ -30,8 +30,8 @@ class MessageTableTest_gifts {
mms.deleteAllThreads()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
}

View File

@@ -40,14 +40,14 @@ class MmsTableTest_stories {
mms.deleteAllThreads()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID())) }
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelRecipient.id)
SignalStore.releaseChannel.setReleaseChannelRecipientId(releaseChannelRecipient.id)
}
@Test

View File

@@ -53,9 +53,9 @@ class RecipientTableTest_getAndPossiblyMerge {
@Before
fun setup() {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
SignalStore.account.setE164(E164_SELF)
SignalStore.account.setAci(ACI_SELF)
SignalStore.account.setPni(PNI_SELF)
}
@Test

View File

@@ -10,7 +10,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Hex
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.addMember
import org.thoughtcrime.securesms.database.model.databaseprotos.addRequestingMember
import org.thoughtcrime.securesms.database.model.databaseprotos.deleteRequestingMember
@@ -44,8 +46,8 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
recipients = SignalDatabase.recipients
sms = SignalDatabase.messages
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
alice = recipients.getOrInsertFromServiceId(aliceServiceId)
bob = recipients.getOrInsertFromServiceId(bobServiceId)
@@ -286,11 +288,18 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingMessage {
wallClock++
val updateDescription = GV2UpdateDescription(
gv2ChangeDescription = groupContext,
groupChangeUpdate = GroupsV2UpdateMessageConverter.translateDecryptedChangeUpdate(SignalStore.account.getServiceIds(), groupContext)
)
return IncomingMessage.groupUpdate(
from = sender,
timestamp = wallClock,
groupId = groupId,
groupContext = groupContext,
update = updateDescription,
isGroupAdd = false,
serverGuid = null
)
}

View File

@@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.audio.AudioHash
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.stickers.StickerLocator
import java.util.UUID
object UriAttachmentBuilder {
fun build(
@@ -22,23 +23,28 @@ object UriAttachmentBuilder {
stickerLocator: StickerLocator? = null,
blurHash: BlurHash? = null,
audioHash: AudioHash? = null,
transformProperties: AttachmentTable.TransformProperties? = null
transformProperties: AttachmentTable.TransformProperties? = null,
uuid: UUID? = UUID.randomUUID()
): UriAttachment {
return UriAttachment(
uri,
contentType,
transferState,
size,
fileName,
voiceNote,
borderless,
videoGif,
quote,
caption,
stickerLocator,
blurHash,
audioHash,
transformProperties
dataUri = uri,
contentType = contentType,
transferState = transferState,
size = size,
width = 0,
height = 0,
fileName = fileName,
fastPreflightId = null,
voiceNote = voiceNote,
borderless = borderless,
videoGif = videoGif,
quote = quote,
caption = caption,
stickerLocator = stickerLocator,
blurHash = blurHash,
audioHash = audioHash,
transformProperties = transformProperties,
uuid = uuid
)
}
}

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.database.InAppPaymentSubscriberTable
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
import org.thoughtcrime.securesms.testing.assertIs
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.math.BigDecimal
import java.util.Currency
@RunWith(AndroidJUnit4::class)
class FixInAppCurrencyIfAbleTest {
@get:Rule
val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
@Test
fun givenNoSubscribers_whenIMigrate_thenIDoNothing() {
migrate()
}
@Test
fun givenASubscriberButNoPayment_whenIMigrate_thenIDoNothing() {
val subscriber = insertSubscriber("USD")
clearCurrencyCode(subscriber)
migrate()
getCurrencyCode(subscriber) assertIs ""
}
@Test
fun givenASubscriberAndMismatchedPayment_whenIMigrate_thenIDoNothing() {
val subscriber = insertSubscriber("USD")
val otherSubscriber = insertSubscriber("EUR")
insertPayment(otherSubscriber)
clearCurrencyCode(subscriber)
migrate()
getCurrencyCode(subscriber) assertIs ""
}
@Test
fun givenASubscriberAndPaymentWithNoSubscriber_whenIMigrate_thenDoNothing() {
val subscriber = insertSubscriber("USD")
insertPayment(null)
clearCurrencyCode(subscriber)
migrate()
getCurrencyCode(subscriber) assertIs ""
}
@Test
fun givenASubscriberAndMatchingPayment_whenIMigrate_thenUpdateCurrencyCode() {
val subscriber = insertSubscriber("USD")
insertPayment(subscriber)
clearCurrencyCode(subscriber)
migrate()
getCurrencyCode(subscriber) assertIs "USD"
}
@Test
fun givenASupercededSubscriber_whenIMigrate_thenIDoNothing() {
val oldSubscriber = insertSubscriber("USD")
insertPayment(oldSubscriber)
clearCurrencyCode(oldSubscriber)
insertSubscriber("USD")
migrate()
}
private fun migrate() {
V236_FixInAppSubscriberCurrencyIfAble.migrate(
context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
db = SignalDatabase.rawDatabase,
oldVersion = 0,
newVersion = 0
)
}
private fun insertSubscriber(currencyCode: String): InAppPaymentSubscriberRecord {
val record = InAppPaymentSubscriberRecord(
subscriberId = SubscriberId.generate(),
currency = Currency.getInstance(currencyCode),
type = InAppPaymentSubscriberRecord.Type.DONATION,
requiresCancel = false,
paymentMethodType = InAppPaymentData.PaymentMethodType.PAYPAL
)
SignalDatabase.inAppPaymentSubscribers.insertOrReplace(record)
return record
}
private fun clearCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord) {
SignalDatabase.rawDatabase.update(InAppPaymentSubscriberTable.TABLE_NAME)
.values(InAppPaymentSubscriberTable.CURRENCY_CODE to "")
.where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize())
.run()
}
private fun getCurrencyCode(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord): String {
return SignalDatabase.rawDatabase.select(InAppPaymentSubscriberTable.CURRENCY_CODE)
.from(InAppPaymentSubscriberTable.TABLE_NAME)
.where("${InAppPaymentSubscriberTable.SUBSCRIBER_ID} = ?", inAppPaymentSubscriberRecord.subscriberId.serialize())
.run()
.readToSingleObject { it.requireNonNullString(InAppPaymentSubscriberTable.CURRENCY_CODE) }!!
}
private fun insertPayment(inAppPaymentSubscriberRecord: InAppPaymentSubscriberRecord?): InAppPaymentTable.InAppPayment {
val id = SignalDatabase.inAppPayments.insert(
type = InAppPaymentType.RECURRING_DONATION,
state = InAppPaymentTable.State.END,
subscriberId = inAppPaymentSubscriberRecord?.subscriberId,
endOfPeriod = null,
inAppPaymentData = InAppPaymentData(
amount = FiatValue(
currencyCode = inAppPaymentSubscriberRecord?.currency?.currencyCode ?: "USD",
amount = BigDecimal.ONE.toDecimalValue()
),
level = 200,
paymentMethodType = inAppPaymentSubscriberRecord?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN
)
)
return SignalDatabase.inAppPayments.getById(id)!!
}
}

View File

@@ -58,18 +58,21 @@ class InstrumentationApplicationDependencyProvider(val application: Application,
Get("/v1/websocket/?login=") {
MockResponse().success().withWebSocketUpgrade(mockIdentifiedWebSocket)
},
Get("/v1/websocket", { !it.path.contains("login") }) {
Get("/v1/websocket", {
val path = it.path
return@Get path == null || !path.contains("login")
}) {
MockResponse().success().withWebSocketUpgrade(object : WebSocketListener() {})
}
)
}
webServer.setDispatcher(object : Dispatcher() {
webServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val handler = handlers.firstOrNull { it.requestPredicate(request) }
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
}
})
}
serviceTrustStore = SignalServiceTrustStore(application)
uncensoredConfiguration = SignalServiceConfiguration(

View File

@@ -1,168 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.CapturingSlot
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.protos.DeleteSyncJobData
import org.thoughtcrime.securesms.messages.MessageHelper
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.testing.assertIsNotNull
import org.thoughtcrime.securesms.testing.assertIsSize
import org.thoughtcrime.securesms.util.MessageTableTestUtils
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.messages.SendMessageResult
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Content
import java.util.Optional
@RunWith(AndroidJUnit4::class)
class MultiDeviceDeleteSendSyncJobTest {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var messageHelper: MessageHelper
private lateinit var success: SendMessageResult
private lateinit var failure: SendMessageResult
private lateinit var content: CapturingSlot<Content>
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
mockkStatic(TextSecurePreferences::class)
every { TextSecurePreferences.isMultiDevice(any()) } answers {
true
}
success = SendMessageResult.success(SignalServiceAddress(Recipient.self().requireServiceId()), listOf(2), true, false, 0, Optional.empty())
failure = SendMessageResult.networkFailure(SignalServiceAddress(Recipient.self().requireServiceId()))
content = slot<Content>()
}
@After
fun tearDown() {
messageHelper.tearDown()
unmockkStatic(TextSecurePreferences::class)
}
@Test
fun messageDeletes() {
// GIVEN
val messages = mutableListOf<MessageHelper.MessageData>()
messages += messageHelper.incomingText()
messages += messageHelper.incomingText()
messages += messageHelper.outgoingText()
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
val records: Set<MessageRecord> = MessageTableTestUtils.getMessages(threadId).toSet()
// WHEN
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns success
val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records)
val result = job.run()
// THEN
result.isSuccess assertIs true
assertDeleteSync(messageHelper.alice, messages)
}
@Test
fun groupMessageDeletes() {
// GIVEN
val messages = mutableListOf<MessageHelper.MessageData>()
messages += messageHelper.incomingText(destination = messageHelper.group.recipientId)
messages += messageHelper.incomingText(destination = messageHelper.group.recipientId)
messages += messageHelper.outgoingText(conversationId = messageHelper.group.recipientId)
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.group.recipientId)!!
val records: Set<MessageRecord> = MessageTableTestUtils.getMessages(threadId).toSet()
// WHEN
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns success
val job = MultiDeviceDeleteSendSyncJob.createMessageDeletes(records)
val result = job.run()
// THEN
result.isSuccess assertIs true
assertDeleteSync(messageHelper.group.recipientId, messages)
}
@Test
fun retryOfDeletes() {
// GIVEN
val alice = messageHelper.alice.toLong()
// WHEN
every { AppDependencies.signalServiceMessageSender.sendSyncMessage(capture(content), any(), any()) } returns failure
val job = MultiDeviceDeleteSendSyncJob(
messages = listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)),
threads = listOf(DeleteSyncJobData.ThreadDelete(alice, listOf(DeleteSyncJobData.AddressableMessage(alice, 1, alice)))),
localOnlyThreads = listOf(DeleteSyncJobData.ThreadDelete(alice))
)
val result = job.run()
val data = DeleteSyncJobData.ADAPTER.decode(job.serialize())
// THEN
result.isRetry assertIs true
data.messageDeletes.assertIsSize(1)
data.threadDeletes.assertIsSize(1)
data.localOnlyThreadDeletes.assertIsSize(1)
}
private fun assertDeleteSync(conversation: RecipientId, inputMessages: List<MessageHelper.MessageData>) {
val messagesMap = inputMessages.associateBy { it.timestamp }
val content = this.content.captured
content.syncMessage?.padding.assertIsNotNull()
content.syncMessage?.deleteForMe.assertIsNotNull()
val deleteForMe = content.syncMessage!!.deleteForMe!!
deleteForMe.messageDeletes.assertIsSize(1)
deleteForMe.conversationDeletes.assertIsSize(0)
deleteForMe.localOnlyConversationDeletes.assertIsSize(0)
val messageDeletes = deleteForMe.messageDeletes[0]
val conversationRecipient = Recipient.resolved(conversation)
if (conversationRecipient.isGroup) {
messageDeletes.conversation!!.threadGroupId assertIs conversationRecipient.requireGroupId().decodedId.toByteString()
} else {
messageDeletes.conversation!!.threadAci assertIs conversationRecipient.requireAci().toString()
}
messageDeletes
.messages
.forEach { delete ->
val messageData = messagesMap[delete.sentTimestamp]
delete.sentTimestamp assertIs messageData!!.timestamp
delete.authorAci assertIs Recipient.resolved(messageData.author).requireAci().toString()
}
}
}

View File

@@ -5,23 +5,31 @@
package org.thoughtcrime.securesms.messages
import android.net.Uri
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.slot
import io.mockk.unmockkStatic
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.UriAttachmentBuilder
import org.thoughtcrime.securesms.database.model.GroupsV2UpdateMessageConverter
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.jobs.ThreadUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.UUID
import kotlin.random.Random
/**
* Makes inserting messages through the "normal" code paths simpler. Mostly focused on incoming messages.
@@ -68,7 +76,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
return messageData
}
fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: (OutgoingMessage.() -> OutgoingMessage)? = null): MessageData {
fun outgoingText(conversationId: RecipientId = alice, successfulSend: Boolean = true, updateMessage: ((OutgoingMessage) -> OutgoingMessage)? = null): MessageData {
startTime = nextStartTime()
val messageData = MessageData(author = harness.self.id, timestamp = startTime)
@@ -80,7 +88,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
sentTimeMillis = messageData.timestamp,
isUrgent = true,
isSecure = true
).apply { updateMessage?.invoke(this) }
).let { updateMessage?.invoke(it) ?: it }
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(message, threadId, false, null)
@@ -111,6 +119,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
return messageData.copy(messageId = messageId)
}
fun outgoingAttachment(data: ByteArray, uuid: UUID? = UUID.randomUUID()): Attachment {
val uri: Uri = BlobProvider.getInstance().forData(data).createForSingleSessionInMemory()
val attachment: UriAttachment = UriAttachmentBuilder.build(
id = Random.nextLong(),
uri = uri,
contentType = MediaUtil.IMAGE_JPEG,
transformProperties = AttachmentTable.TransformProperties(),
uuid = uuid
)
return attachment
}
fun outgoingGroupChange(): MessageData {
startTime = nextStartTime()
@@ -123,7 +145,7 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
val updateDescription = GV2UpdateDescription.Builder()
.gv2ChangeDescription(decryptedGroupV2Context)
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account().getServiceIds(), decryptedGroupV2Context))
.groupChangeUpdate(GroupsV2UpdateMessageConverter.translateDecryptedChange(SignalStore.account.getServiceIds(), decryptedGroupV2Context))
.build()
val outgoingMessage = OutgoingMessage.groupUpdateMessage(groupRecipient, updateDescription, startTime)
@@ -238,6 +260,20 @@ class MessageHelper(private val harness: SignalActivityRule, var startTime: Long
return messageData
}
fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair<RecipientId, Long>, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): MessageData {
startTime = nextStartTime()
val messageData = MessageData(timestamp = startTime)
processor.process(
envelope = MessageContentFuzzer.envelope(messageData.timestamp, serverGuid = messageData.serverGuid),
content = MessageContentFuzzer.syncDeleteForMeAttachment(conversationId, message, uuid, digest, plainTextHash),
metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, harness.self.id, sourceDeviceId = 2),
serverDeliveredTimestamp = messageData.timestamp + 10
)
return messageData
}
/**
* Get the next "sentTimestamp" for current + [nextMessageOffset]th message. Useful for early message processing and future message timestamps.
*/

View File

@@ -16,7 +16,7 @@ import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.AliceClient
@@ -55,8 +55,8 @@ class MessageProcessingPerformanceTest {
@Before
fun setup() {
mockkStatic(UnidentifiedAccessUtil::class)
every { UnidentifiedAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkStatic(SealedSenderAccessUtil::class)
every { SealedSenderAccessUtil.getCertificateValidator() } returns FakeClientHelpers.noOpCertificateValidator
mockkObject(MessageContentProcessor)
every { MessageContentProcessor.create(harness.application) } returns TimingMessageContentProcessor(harness.application)
@@ -64,7 +64,7 @@ class MessageProcessingPerformanceTest {
@After
fun after() {
unmockkStatic(UnidentifiedAccessUtil::class)
unmockkStatic(SealedSenderAccessUtil::class)
unmockkStatic(MessageContentProcessor::class)
}

View File

@@ -6,15 +6,19 @@
package org.thoughtcrime.securesms.messages
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.hamcrest.Matchers.greaterThan
import org.junit.After
import org.junit.Before
import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.logging.Log
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
@@ -25,14 +29,20 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.assert
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.testing.assertIsNot
import org.thoughtcrime.securesms.testing.assertIsNotNull
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.testing.assertIsSize
import org.thoughtcrime.securesms.util.IdentityUtil
import java.util.UUID
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class SyncMessageProcessorTest_synchronizeDeleteForMe {
companion object {
private val TAG = "SyncDeleteForMeTest"
}
@get:Rule
val harness = SignalActivityRule(createGroup = true)
@@ -41,16 +51,11 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
@Before
fun setUp() {
messageHelper = MessageHelper(harness)
mockkStatic(FeatureFlags::class)
every { FeatureFlags.deleteSyncEnabled() } returns true
}
@After
fun tearDown() {
messageHelper.tearDown()
unmockkStatic(FeatureFlags::class)
}
@Test
@@ -256,7 +261,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
}
messageHelper.syncDeleteForMeConversation(
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, true)
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, isFullDelete = true)
)
// THEN
@@ -268,7 +273,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
}
@Test
fun localOnlyRemainingAfterConversationDeleteWithFullDelete() {
fun singleConversationNoRecentsFoundNonExpiringRecentsFoundDelete() {
// GIVEN
val messages = mutableListOf<MessageTable.SyncMessageId>()
@@ -277,15 +282,52 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp)
}
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 20
// WHEN
val nonExpiringMessages = messages.takeLast(5).map { it.recipientId to it.timetamp }
val randomFutureMessages = (1..5).map {
messageHelper.alice to messageHelper.nextStartTime(it)
}
messageHelper.syncDeleteForMeConversation(
DeleteForMeSync(conversationId = messageHelper.alice, randomFutureMessages, nonExpiringMessages, true)
)
// THEN
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0
SignalDatabase.threads.getThreadRecord(threadId) assertIs null
harness.inMemoryLogger.flush()
harness.inMemoryLogger.entries().filter { it.message?.contains("Using backup non-expiring messages") == true }.size assertIs 1
}
@Test
fun localOnlyRemainingAfterConversationDeleteWithFullDelete() {
// GIVEN
val messages = mutableListOf<MessageTable.SyncMessageId>()
Log.v(TAG, "Adding normal messages")
for (i in 0 until 10) {
messages += MessageTable.SyncMessageId(messageHelper.alice, messageHelper.incomingText().timestamp)
messages += MessageTable.SyncMessageId(harness.self.id, messageHelper.outgoingText().timestamp)
}
val alice = Recipient.resolved(messageHelper.alice)
Log.v(TAG, "Adding identity message")
IdentityUtil.markIdentityVerified(harness.context, alice, true, true)
Log.v(TAG, "Adding profile message")
SignalDatabase.messages.insertProfileNameChangeMessages(alice, "new name", "previous name")
Log.v(TAG, "Adding call message")
SignalDatabase.calls.insertOneToOneCall(1, System.currentTimeMillis(), alice.id, CallTable.Type.AUDIO_CALL, CallTable.Direction.OUTGOING, CallTable.Event.ACCEPTED)
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 23
// WHEN
Log.v(TAG, "Processing sync message")
messageHelper.syncDeleteForMeConversation(
DeleteForMeSync(
conversationId = messageHelper.alice,
@@ -380,8 +422,8 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
// WHEN
messageHelper.syncDeleteForMeConversation(
DeleteForMeSync(conversationId = messageHelper.alice, allMessages[messageHelper.alice]!!.takeLast(5).map { it.recipientId to it.timetamp }, true),
DeleteForMeSync(conversationId = messageHelper.bob, allMessages[messageHelper.bob]!!.takeLast(5).map { it.recipientId to it.timetamp }, true)
DeleteForMeSync(conversationId = messageHelper.alice, allMessages[messageHelper.alice]!!.takeLast(5).map { it.recipientId to it.timetamp }, isFullDelete = true),
DeleteForMeSync(conversationId = messageHelper.bob, allMessages[messageHelper.bob]!!.takeLast(5).map { it.recipientId to it.timetamp }, isFullDelete = true)
)
// THEN
@@ -418,6 +460,7 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
SignalDatabase.threads.getThreadRecord(aliceThreadId) assertIs null
}
@Ignore("counts are consistent for some reason")
@Test
fun multipleLocalOnlyConversation() {
// GIVEN
@@ -468,8 +511,10 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
SignalDatabase.messages.deleteMessage(messageId = oneToOnePlaceHolderMessage, threadId = aliceThreadId, notify = false, updateThread = false)
SignalDatabase.messages.deleteMessage(messageId = groupPlaceholderMessage, threadId = aliceThreadId, notify = false, updateThread = false)
SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 16
SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 10
SignalDatabase.rawDatabase.withinTransaction {
SignalDatabase.messages.getMessageCountForThread(aliceThreadId) assertIs 16
SignalDatabase.messages.getMessageCountForThread(groupThreadId) assertIs 10
}
// WHEN
messageHelper.syncDeleteForMeLocalOnlyConversation(messageHelper.alice, messageHelper.group.recipientId)
@@ -505,4 +550,154 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe {
harness.inMemoryLogger.flush()
harness.inMemoryLogger.entries().filter { it.message?.contains("Thread is not local only") == true }.size assertIs 1
}
@Test
fun singleAttachmentDeletes() {
// GIVEN
val message1 = messageHelper.outgoingText { message ->
message.copy(
attachments = listOf(
messageHelper.outgoingAttachment(byteArrayOf(1, 2, 3)),
messageHelper.outgoingAttachment(byteArrayOf(2, 3, 4), null),
messageHelper.outgoingAttachment(byteArrayOf(5, 6, 7), null),
messageHelper.outgoingAttachment(byteArrayOf(10, 11, 12))
)
)
}
var attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
attachments assertIsSize 4
val threadId = SignalDatabase.threads.getThreadIdFor(messageHelper.alice)!!
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
// Has all three
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[0].attachmentId,
attachment = attachments[0].copy(digest = byteArrayOf(attachments[0].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
)
// Missing uuid and digest
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[1].attachmentId,
attachment = attachments[1],
uploadTimestamp = message1.timestamp + 1
)
// Missing uuid and plain text
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[2].attachmentId,
attachment = attachments[2].copy(digest = byteArrayOf(attachments[2].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
)
SignalDatabase.rawDatabase.update(AttachmentTable.TABLE_NAME).values(AttachmentTable.DATA_HASH_END to null).where("${AttachmentTable.ID} = ?", attachments[2].attachmentId).run()
// Different has all three
SignalDatabase.attachments.finalizeAttachmentAfterUpload(
id = attachments[3].attachmentId,
attachment = attachments[3].copy(digest = byteArrayOf(attachments[3].attachmentId.id.toByte())),
uploadTimestamp = message1.timestamp + 1
)
attachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
// WHEN
messageHelper.syncDeleteForMeAttachment(
conversationId = messageHelper.alice,
message = message1.author to message1.timestamp,
attachments[0].uuid,
attachments[0].remoteDigest,
attachments[0].dataHash
)
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
var updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
updatedAttachments assertIsSize 3
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[0].attachmentId }
messageHelper.syncDeleteForMeAttachment(
conversationId = messageHelper.alice,
message = message1.author to message1.timestamp,
attachments[1].uuid,
attachments[1].remoteDigest,
attachments[1].dataHash
)
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
updatedAttachments assertIsSize 2
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[1].attachmentId }
messageHelper.syncDeleteForMeAttachment(
conversationId = messageHelper.alice,
message = message1.author to message1.timestamp,
attachments[2].uuid,
attachments[2].remoteDigest,
attachments[2].dataHash
)
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 1
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
updatedAttachments assertIsSize 1
updatedAttachments.forEach { it.attachmentId assertIsNot attachments[2].attachmentId }
messageHelper.syncDeleteForMeAttachment(
conversationId = messageHelper.alice,
message = message1.author to message1.timestamp,
attachments[3].uuid,
attachments[3].remoteDigest,
attachments[3].dataHash
)
SignalDatabase.messages.getMessageCountForThread(threadId) assertIs 0
updatedAttachments = SignalDatabase.attachments.getAttachmentsForMessage(message1.messageId)
updatedAttachments assertIsSize 0
SignalDatabase.threads.getThreadRecord(threadId) assertIs null
}
private fun DatabaseAttachment.copy(
uuid: UUID? = this.uuid,
digest: ByteArray? = this.remoteDigest
): Attachment {
return DatabaseAttachment(
attachmentId = this.attachmentId,
mmsId = this.mmsId,
hasData = this.hasData,
hasThumbnail = false,
hasArchiveThumbnail = false,
contentType = this.contentType,
transferProgress = this.transferState,
size = this.size,
fileName = this.fileName,
cdn = this.cdn,
location = this.remoteLocation,
key = this.remoteKey,
digest = digest,
incrementalDigest = this.incrementalDigest,
incrementalMacChunkSize = this.incrementalMacChunkSize,
fastPreflightId = this.fastPreflightId,
voiceNote = this.voiceNote,
borderless = this.borderless,
videoGif = this.videoGif,
width = this.width,
height = this.height,
quote = this.quote,
caption = this.caption,
stickerLocator = this.stickerLocator,
blurHash = this.blurHash,
audioHash = this.audioHash,
transformProperties = this.transformProperties,
displayOrder = this.displayOrder,
uploadTimestamp = this.uploadTimestamp,
dataHash = this.dataHash,
archiveCdn = this.archiveCdn,
archiveThumbnailCdn = this.archiveThumbnailCdn,
archiveMediaName = this.archiveMediaName,
archiveMediaId = this.archiveMediaId,
thumbnailRestoreState = this.thumbnailRestoreState,
uuid = uuid
)
}
}

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.testing.assertIsNotNull
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import java.util.Currency
@RunWith(AndroidJUnit4::class)
class SubscriberIdMigrationJobTest {
@@ -35,10 +36,10 @@ class SubscriberIdMigrationJobTest {
@Test
fun givenUSDSubscriber_whenIRunSubscriberIdMigrationJob_thenIExpectASingleEntry() {
val subscriberId = SubscriberId.generate()
SignalStore.donationsValues().setSubscriberCurrency("USD", InAppPaymentSubscriberRecord.Type.DONATION)
SignalStore.donationsValues().setSubscriber("USD", subscriberId)
SignalStore.donationsValues().setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt = true
SignalStore.inAppPayments.setSubscriberCurrency(Currency.getInstance("USD"), InAppPaymentSubscriberRecord.Type.DONATION)
SignalStore.inAppPayments.setSubscriber("USD", subscriberId)
SignalStore.inAppPayments.setSubscriptionPaymentSourceType(PaymentSourceType.PayPal)
SignalStore.inAppPayments.shouldCancelSubscriptionBeforeNextSubscribeAttempt = true
testSubject.run()
@@ -48,7 +49,7 @@ class SubscriberIdMigrationJobTest {
actual!!.subscriberId.bytes assertIs subscriberId.bytes
actual.paymentMethodType assertIs InAppPaymentData.PaymentMethodType.PAYPAL
actual.requiresCancel assertIs true
actual.currencyCode assertIs "USD"
actual.currency assertIs Currency.getInstance("USD")
actual.type assertIs InAppPaymentSubscriberRecord.Type.DONATION
}
}

View File

@@ -24,9 +24,9 @@ class ContactRecordProcessorTest {
@Before
fun setup() {
SignalStore.account().setE164(E164_SELF)
SignalStore.account().setAci(ACI_SELF)
SignalStore.account().setPni(PNI_SELF)
SignalStore.account.setE164(E164_SELF)
SignalStore.account.setAci(ACI_SELF)
SignalStore.account.setPni(PNI_SELF)
}
@Test

View File

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.testing
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.ecc.ECKeyPair
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.protocol.BufferedProtocolStore
@@ -30,7 +29,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
uuid = serviceId.rawUuid,
e164 = e164,
deviceId = 1,
identityKey = SignalStore.account().aciIdentityKey.publicKey.publicKey,
identityKey = SignalStore.account.aciIdentityKey.publicKey.publicKey,
expires = 31337
)
@@ -50,7 +49,7 @@ class AliceClient(val serviceId: ServiceId, val e164: String, val trustRoot: ECK
fun encrypt(now: Long, destination: Recipient): Envelope {
return AppDependencies.signalServiceMessageSender.getEncryptedMessage(
SignalServiceAddress(destination.requireServiceId(), destination.requireE164()),
FakeClientHelpers.getTargetUnidentifiedAccess(ProfileKeyUtil.getSelfProfileKey(), ProfileKey(destination.profileKey), aliceSenderCertificate),
FakeClientHelpers.getSealedSenderAccess(ProfileKey(destination.profileKey), aliceSenderCertificate),
1,
FakeClientHelpers.encryptedTextMessage(now),
false

View File

@@ -17,7 +17,7 @@ import org.signal.libsignal.protocol.state.SignedPreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignedPreKeyTable
@@ -25,14 +25,13 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore
import org.whispersystems.signalservice.api.SignalSessionLock
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher
import org.whispersystems.signalservice.api.crypto.SignalSessionBuilder
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.internal.push.Envelope
import java.util.Optional
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
@@ -75,12 +74,12 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, SealedSenderAccessUtil.getCertificateValidator())
cipher.decrypt(envelope, serverDeliveredTimestamp)
}
private fun getAliceServiceId(): ServiceId {
return SignalStore.account().requireAci()
return SignalStore.account.requireAci()
}
private fun getAlicePreKeyBundle(): PreKeyBundle {
@@ -103,7 +102,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
val selfSignedPreKeyRecord = SignalDatabase.signedPreKeys.get(getAliceServiceId(), selfSignedPreKeyId)!!
return PreKeyBundle(
SignalStore.account().registrationId,
SignalStore.account.registrationId,
1,
selfPreKeyId,
selfPreKeyRecord.keyPair.publicKey,
@@ -115,19 +114,19 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
private fun getAliceProtocolAddress(): SignalProtocolAddress {
return SignalProtocolAddress(SignalStore.account().requireAci().toString(), 1)
return SignalProtocolAddress(SignalStore.account.requireAci().toString(), 1)
}
private fun getAlicePublicKey(): IdentityKey {
return SignalStore.account().aciIdentityKey.publicKey
return SignalStore.account.aciIdentityKey.publicKey
}
private fun getAliceProfileKey(): ProfileKey {
return ProfileKeyUtil.getSelfProfileKey()
}
private fun getAliceUnidentifiedAccess(): Optional<UnidentifiedAccess> {
return FakeClientHelpers.getTargetUnidentifiedAccess(profileKey, getAliceProfileKey(), senderCertificate)
private fun getAliceUnidentifiedAccess(): SealedSenderAccess? {
return FakeClientHelpers.getSealedSenderAccess(getAliceProfileKey(), senderCertificate)
}
private class BobSignalServiceAccountDataStore(private val registrationId: Int, private val identityKeyPair: IdentityKeyPair) : SignalServiceAccountDataStore {
@@ -144,7 +143,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
}
override fun getSubDeviceSessions(name: String?): List<Int> = emptyList()
override fun containsSession(address: SignalProtocolAddress?): Boolean = aliceSessionRecord != null
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account().aciIdentityKey.publicKey
override fun getIdentity(address: SignalProtocolAddress?): IdentityKey = SignalStore.account.aciIdentityKey.publicKey
override fun loadPreKey(preKeyId: Int): PreKeyRecord = throw UnsupportedOperationException()
override fun storePreKey(preKeyId: Int, record: PreKeyRecord?) = throw UnsupportedOperationException()
override fun containsPreKey(preKeyId: Int): Boolean = throw UnsupportedOperationException()

View File

@@ -14,8 +14,8 @@ import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.crypto.SealedSenderAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
@@ -46,11 +46,10 @@ object FakeClientHelpers {
}
}
fun getTargetUnidentifiedAccess(myProfileKey: ProfileKey, theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): Optional<UnidentifiedAccess> {
val selfUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(myProfileKey)
val themUnidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey)
fun getSealedSenderAccess(theirProfileKey: ProfileKey, senderCertificate: SenderCertificate): SealedSenderAccess? {
val themUnidentifiedAccessKey = UnidentifiedAccess(UnidentifiedAccess.deriveAccessKeyFrom(theirProfileKey), senderCertificate.serialized, false)
return UnidentifiedAccessPair(UnidentifiedAccess(selfUnidentifiedAccessKey, senderCertificate.serialized, false), UnidentifiedAccess(themUnidentifiedAccessKey, senderCertificate.serialized, false)).targetUnidentifiedAccess
return SealedSenderAccess.forIndividual(themUnidentifiedAccessKey)
}
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {

View File

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

View File

@@ -2,12 +2,15 @@ package org.thoughtcrime.securesms.testing
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
import org.thoughtcrime.securesms.messages.TestMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.BodyRange
import org.whispersystems.signalservice.internal.push.Content
@@ -162,12 +165,12 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
},
messages = conversationDeletes.map { (author, timestamp) ->
SyncMessage.DeleteForMe.AddressableMessage(
authorAci = Recipient.resolved(author).requireAci().toString(),
authorServiceId = Recipient.resolved(author).requireAci().toString(),
sentTimestamp = timestamp
)
}
@@ -184,23 +187,30 @@ object MessageContentFuzzer {
.syncMessage(
SyncMessage(
deleteForMe = SyncMessage.DeleteForMe(
conversationDeletes = allDeletes.map { (conversationId, conversationDeletes, isFullDelete) ->
val conversation = Recipient.resolved(conversationId)
conversationDeletes = allDeletes.map { delete ->
val conversation = Recipient.resolved(delete.conversationId)
SyncMessage.DeleteForMe.ConversationDelete(
conversation = if (conversation.isGroup) {
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
},
mostRecentMessages = conversationDeletes.map { (author, timestamp) ->
mostRecentMessages = delete.messages.map { (author, timestamp) ->
SyncMessage.DeleteForMe.AddressableMessage(
authorAci = Recipient.resolved(author).requireAci().toString(),
authorServiceId = Recipient.resolved(author).requireAci().toString(),
sentTimestamp = timestamp
)
},
isFullDelete = isFullDelete
mostRecentNonExpiringMessages = delete.nonExpiringMessages.map { (author, timestamp) ->
SyncMessage.DeleteForMe.AddressableMessage(
authorServiceId = Recipient.resolved(author).requireAci().toString(),
sentTimestamp = timestamp
)
},
isFullDelete = delete.isFullDelete
)
}
)
@@ -220,7 +230,7 @@ object MessageContentFuzzer {
conversation = if (conversation.isGroup) {
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
SyncMessage.DeleteForMe.ConversationIdentifier(threadAci = conversation.requireAci().toString())
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
}
)
}
@@ -229,6 +239,35 @@ object MessageContentFuzzer {
).build()
}
fun syncDeleteForMeAttachment(conversationId: RecipientId, message: Pair<RecipientId, Long>, uuid: UUID?, digest: ByteArray?, plainTextHash: String?): Content {
val conversation = Recipient.resolved(conversationId)
return Content
.Builder()
.syncMessage(
SyncMessage(
deleteForMe = SyncMessage.DeleteForMe(
attachmentDeletes = listOf(
SyncMessage.DeleteForMe.AttachmentDelete(
conversation = if (conversation.isGroup) {
SyncMessage.DeleteForMe.ConversationIdentifier(threadGroupId = conversation.requireGroupId().decodedId.toByteString())
} else {
SyncMessage.DeleteForMe.ConversationIdentifier(threadServiceId = conversation.requireAci().toString())
},
targetMessage = SyncMessage.DeleteForMe.AddressableMessage(
authorServiceId = Recipient.resolved(message.first).requireAci().toString(),
sentTimestamp = message.second
),
uuid = uuid?.let { UuidUtil.toByteString(it) },
fallbackDigest = digest?.toByteString(),
fallbackPlaintextHash = plainTextHash?.let { Base64.decodeOrNull(it)?.toByteString() }
)
)
)
)
).build()
}
/**
* Create a random media message that may be:
* - A text body
@@ -373,7 +412,9 @@ object MessageContentFuzzer {
data class DeleteForMeSync(
val conversationId: RecipientId,
val messages: List<Pair<RecipientId, Long>>,
val isFullDelete: Boolean = true
val nonExpiringMessages: List<Pair<RecipientId, Long>> = emptyList(),
val isFullDelete: Boolean = true,
val attachments: List<Pair<Long, AttachmentTable.SyncAttachmentId>> = emptyList()
) {
constructor(conversationId: RecipientId, vararg messages: Pair<RecipientId, Long>) : this(conversationId, messages.toList())
}

View File

@@ -68,7 +68,7 @@ object MockProvider {
}
}
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account().aciIdentityKey, deviceId: Int): PreKeyResponse {
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account.aciIdentityKey, deviceId: Int): PreKeyResponse {
val signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), identity.privateKey)
val oneTimePreKey = PreKeyRecord(SecureRandom().nextInt(Medium.MAX_VALUE), Curve.generateKeyPair())

View File

@@ -55,5 +55,5 @@ inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
}
private fun defaultRequestPredicate(verb: String, path: String, predicate: RequestPredicate = { true }): RequestPredicate = { request ->
request.method == verb && request.path.startsWith("/$path") && predicate(request)
request.method == verb && request.path?.startsWith("/$path") == true && predicate(request)
}

View File

@@ -90,8 +90,8 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
preferences.edit().putBoolean("passphrase_initialized", true).commit()
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
val registrationRepository = RegistrationRepository(application)
@@ -111,19 +111,19 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro
verifyAccountResponse = VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().pniPreKeys)
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
),
false
).blockingGet()
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
SignalStore.svr().optOut()
SignalStore.svr.optOut()
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
SignalStore.settings().isMessageNotificationsEnabled = false
SignalStore.settings.isMessageNotificationsEnabled = false
return Recipient.self()
}
@@ -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))
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, false))
SignalDatabase.recipients.setProfileSharing(recipientId, true)
SignalDatabase.recipients.markRegistered(recipientId, aci)
val otherIdentity = IdentityKeyUtil.generateIdentityKeyPair()

View File

@@ -26,8 +26,8 @@ class SignalDatabaseRule(
override fun starting(description: Description?) {
deleteAllThreads()
SignalStore.account().setAci(localAci)
SignalStore.account().setPni(localPni)
SignalStore.account.setAci(localAci)
SignalStore.account.setPni(localPni)
}
override fun finished(description: Description?) {

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.testing
import android.database.Cursor
import android.util.Base64
import org.hamcrest.Matcher
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasSize
@@ -9,6 +8,7 @@ import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.not
import org.hamcrest.Matchers.notNullValue
import org.hamcrest.Matchers.nullValue
import org.signal.core.util.Hex
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.select
@@ -67,27 +67,31 @@ fun CountDownLatch.awaitFor(duration: Duration) {
}
}
fun dumpTableToLogs(tag: String = "TestUtils", table: String) {
dumpTable(table).forEach { Log.d(tag, it.toString()) }
fun dumpTableToLogs(tag: String = "TestUtils", table: String, columns: Set<String>? = null) {
dumpTable(table, columns).forEach { Log.d(tag, it.toString()) }
}
fun dumpTable(table: String): List<List<Pair<String, String?>>> {
fun dumpTable(table: String, columns: Set<String>?): List<List<Pair<String, String?>>> {
return SignalDatabase.rawDatabase
.select()
.from(table)
.run()
.readToList { cursor ->
val map: List<Pair<String, String?>> = cursor.columnNames.map { column ->
val index = cursor.getColumnIndex(column)
var data: String? = when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> Base64.encodeToString(cursor.getBlob(index), 0)
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
val map: List<Pair<String, String?>> = cursor.columnNames.mapNotNull { column ->
if (columns == null || columns.contains(column)) {
val index = cursor.getColumnIndex(column)
var data: String? = when (cursor.getType(index)) {
Cursor.FIELD_TYPE_BLOB -> Hex.toStringCondensed(cursor.getBlob(index))
else -> cursor.getString(index)
}
if (table == MessageTable.TABLE_NAME && column == MessageTable.TYPE) {
data = MessageTableTestUtils.typeColumnToString(cursor.getLong(index))
}
column to data
column to data
} else {
null
}
}
map
}

View File

@@ -1,11 +0,0 @@
package org.thoughtcrime.securesms.util;
/**
* A class that allows us to inject feature flags during tests.
*/
public final class FeatureFlagsAccessor {
public static void forceValue(String key, Object value) {
FeatureFlags.FORCED_VALUES.put(key, value);
}
}

View File

@@ -4,7 +4,7 @@ import android.content.Context
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.RemoteConfig
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.account.PreKeyUpload
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@@ -23,8 +23,8 @@ class DummyAccountManagerFactory : AccountManagerFactory() {
deviceId,
password,
BuildConfig.SIGNAL_AGENT,
FeatureFlags.okHttpAutomaticRetry(),
FeatureFlags.groupLimits().hardLimit
RemoteConfig.okHttpAutomaticRetry,
RemoteConfig.groupLimits.hardLimit
)
}

View File

@@ -161,7 +161,8 @@ object TestMessages {
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
System.currentTimeMillis(),
null
)
}
@@ -184,7 +185,8 @@ object TestMessages {
false,
Optional.empty(),
Optional.empty(),
System.currentTimeMillis()
System.currentTimeMillis(),
null
)
}

View File

@@ -44,8 +44,8 @@ object TestUsers {
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
preferences.edit().putBoolean("passphrase_initialized", true).commit()
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
SignalStore.account.generateAciIdentityKeyIfNecessary()
SignalStore.account.generatePniIdentityKeyIfNecessary()
val registrationRepository = RegistrationRepository(application)
val registrationData = RegistrationData(
@@ -63,8 +63,8 @@ object TestUsers {
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false),
masterKey = null,
pin = null,
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account().aciIdentityKey, SignalStore.account().pniPreKeys)
aciPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.aciPreKeys),
pniPreKeyCollection = RegistrationRepository.generateSignedAndLastResortPreKeys(SignalStore.account.aciIdentityKey, SignalStore.account.pniPreKeys)
)
AccountManagerFactory.setInstance(DummyAccountManagerFactory())
@@ -77,7 +77,7 @@ object TestUsers {
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
SignalStore.svr().optOut()
SignalStore.svr.optOut()
RegistrationUtil.maybeMarkRegistrationComplete()
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))

View File

@@ -231,6 +231,10 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onChangeProfileNameUpdateContact(recipient: Recipient) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onCallToAction(action: String) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}

View File

@@ -610,7 +610,7 @@
<activity android:name=".conversation.v2.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:parentActivityName=".MainActivity"
android:resizeableActivity="true"
android:exported="false">
@@ -661,7 +661,7 @@
<activity android:name=".PassphrasePromptActivity"
android:launchMode="singleTask"
android:theme="@style/TextSecure.LightIntroTheme"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
@@ -748,13 +748,6 @@
android:windowSoftInputMode="stateAlwaysHidden"
android:exported="false"/>
<activity
android:name=".backup.v2.ui.subscription.MessageBackupsFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".stories.settings.StorySettingsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -783,12 +776,6 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".components.settings.app.changenumber.v2.ChangeNumberLockV2Activity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity
android:name=".components.settings.conversation.ConversationSettingsActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
@@ -803,13 +790,6 @@
android:windowSoftInputMode="stateAlwaysHidden"
android:exported="false"/>
<activity
android:name=".badges.gifts.flow.GiftFlowActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden"
android:exported="false"/>
<activity
android:name=".wallpaper.ChatWallpaperActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
@@ -836,14 +816,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".registration.RegistrationNavigationActivity"
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=".registration.v2.ui.RegistrationV2Activity"
<activity android:name=".registration.ui.RegistrationActivity"
android:launchMode="singleTask"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateHidden"
@@ -988,8 +961,8 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".backup.v2.ui.subscription.MessageBackupsTestRestoreActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
<activity android:name=".registration.ui.restore.RemoteRestoreActivity"
android:theme="@style/Signal.DayNight.NoActionBar"
android:exported="false"/>
<activity android:name=".profiles.manage.EditProfileActivity"
@@ -1105,7 +1078,7 @@
android:theme="@style/Theme.Signal.WallpaperCropper"
android:exported="false"/>
<activity android:name=".components.settings.app.usernamelinks.main.UsernameQrImageSelectionActivity"
<activity android:name=".components.settings.app.usernamelinks.main.QrImageSelectionActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:theme="@style/TextSecure.DarkNoActionBar"
android:exported="false"/>
@@ -1120,10 +1093,10 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".components.settings.app.subscription.donate.DonateToSignalActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".components.settings.app.subscription.donate.CheckoutFlowActivity"
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<service
android:enabled="true"

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,7 @@ object AppCapabilities {
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
return AccountAttributes.Capabilities(
storage = storageCapable,
senderKey = true,
announcementGroup = true,
changeNumber = true,
stories = true,
giftBadges = true,
pni = true,
paymentActivation = true
deleteSync = true
)
}
}

View File

@@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
import org.thoughtcrime.securesms.emoji.JumboEmoji;
import org.thoughtcrime.securesms.gcm.FcmFetchManager;
import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
@@ -76,6 +77,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
import org.thoughtcrime.securesms.logging.PersistentLogger;
import org.thoughtcrime.securesms.messageprocessingalarm.RoutineMessageFetchReceiver;
import org.thoughtcrime.securesms.messages.GroupSendEndorsementInternalNotifier;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
import org.thoughtcrime.securesms.mms.SignalGlideModule;
@@ -97,7 +99,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -143,10 +145,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
long startTime = System.currentTimeMillis();
if (FeatureFlags.internalUser()) {
Tracer.getInstance().setMaxBufferSize(35_000);
}
super.onCreate();
AppStartup.getInstance().addBlocking("sqlcipher-init", () -> {
@@ -159,12 +157,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
initializeLogging();
Log.i(TAG, "onCreate()");
})
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("anr-detector", this::startAnrDetector)
.addBlocking("security-provider", this::initializeSecurityProvider)
.addBlocking("crash-handling", this::initializeCrashHandling)
.addBlocking("rx-init", this::initializeRx)
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
.addBlocking("app-dependencies", this::initializeAppDependencies)
.addBlocking("scrubber", () -> Scrubber.setIdentifierHmacKeyProvider(() -> SignalStore.svr().getOrCreateMasterKey().deriveLoggingKey()))
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
.addBlocking("app-migrations", this::initializeApplicationMigrations)
@@ -178,9 +176,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
})
.addBlocking("blob-provider", this::initializeBlobProvider)
.addBlocking("feature-flags", FeatureFlags::init)
.addBlocking("remote-config", RemoteConfig::init)
.addBlocking("ring-rtc", this::initializeRingRtc)
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
.addBlocking("tracer", this::initializeTracer)
.addNonBlocking(() -> RegistrationUtil.maybeMarkRegistrationComplete())
.addNonBlocking(() -> Glide.get(this))
.addNonBlocking(this::cleanAvatarStorage)
@@ -206,7 +205,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(RefreshSvrCredentialsJob::enqueueIfNecessary)
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), RemoteConfig.retryRespondMaxAge()))
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
@@ -221,6 +220,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addPostRender(GroupRingCleanupJob::enqueue)
.addPostRender(LinkedDeviceInactiveCheckJob::enqueueIfNecessary)
.addPostRender(() -> ActiveCallManager.clearNotifications(this))
.addPostRender(() -> GroupSendEndorsementInternalNotifier.init())
.execute();
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
@@ -237,12 +237,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
AppDependencies.getMegaphoneRepository().onAppForegrounded();
AppDependencies.getDeadlockDetector().start();
InAppPaymentKeepAliveJob.enqueueAndTrackTimeIfNecessary();
AppDependencies.getJobManager().add(new InAppPaymentAuthCheckJob());
FcmFetchManager.onForeground(this);
startAnrDetector();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
InAppPaymentAuthCheckJob.enqueueIfNeeded();
RemoteConfig.refreshIfNecessary();
RetrieveProfileJob.enqueueRoutineFetchIfNecessary();
executePendingContactSync();
KeyCachingService.onAppForegrounded(this);
@@ -255,7 +255,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
long timeDiff = currentTime - lastForegroundTime;
if (timeDiff < 0) {
Log.w(TAG, "Time travel! The system clock has moved backwards. (currentTime: " + currentTime + " ms, lastForegroundTime: " + lastForegroundTime + " ms, diff: " + timeDiff + " ms)");
Log.w(TAG, "Time travel! The system clock has moved backwards. (currentTime: " + currentTime + " ms, lastForegroundTime: " + lastForegroundTime + " ms, diff: " + timeDiff + " ms)", true);
}
SignalStore.misc().setLastForegroundTime(currentTime);
@@ -277,9 +277,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
public void checkBuildExpiration() {
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
Log.w(TAG, "Build expired!");
SignalStore.misc().setClientDeprecated(true);
if (Util.getTimeUntilBuildExpiry(SignalStore.misc().getEstimatedServerTime()) <= 0 && !SignalStore.misc().isClientDeprecated()) {
Log.w(TAG, "Build potentially expired! Enqueing job to check.", true);
AppDependencies.getJobManager().add(new BuildExpirationConfirmationJob());
}
}
@@ -288,7 +288,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
* This is so we can capture ANR's that happen on boot before the foreground event.
*/
private void startAnrDetector() {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), FeatureFlags::internalUser, (dumps) -> {
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), RemoteConfig::internalUser, (dumps) -> {
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
return Unit.INSTANCE;
});
@@ -313,9 +313,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
@VisibleForTesting
protected void initializeLogging() {
Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this));
Log.initialize(RemoteConfig::internalUser, new AndroidLogger(), new PersistentLogger(this));
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
SignalProtocolLoggerProvider.initializeLogging(BuildConfig.LIBSIGNAL_LOG_LEVEL);
SignalExecutors.UNBOUNDED.execute(() -> {
Log.blockUntilAllWritesFinished();
@@ -386,9 +387,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeFcmCheck() {
if (SignalStore.account().isRegistered()) {
long nextSetTime = SignalStore.account().getFcmTokenLastSetTime() + TimeUnit.HOURS.toMillis(6);
long lastSetTime = SignalStore.account().getFcmTokenLastSetTime();
long nextSetTime = lastSetTime + TimeUnit.HOURS.toMillis(6);
long now = System.currentTimeMillis();
if (SignalStore.account().getFcmToken() == null || nextSetTime <= System.currentTimeMillis()) {
if (SignalStore.account().getFcmToken() == null || nextSetTime <= now || lastSetTime > now) {
AppDependencies.getJobManager().add(new FcmRefreshJob());
}
}
@@ -417,6 +420,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
private void initializeTracer() {
if (RemoteConfig.internalUser()) {
Tracer.getInstance().setMaxBufferSize(35_000);
}
}
private void initializePeriodicTasks() {
RotateSignedPreKeyListener.schedule(this);
DirectoryRefreshListener.schedule(this);
@@ -434,12 +443,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
private void initializeRingRtc() {
try {
Map<String, String> fieldTrials = new HashMap<>();
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
if (RemoteConfig.callingFieldTrialAnyAddressPortsKillSwitch()) {
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
}
if (!SignalStore.internalValues().callingDisableLBRed()) {
fieldTrials.put("RingRTC-Audio-LBRed-For-Opus", "Enabled,bitrate_pri:22000");
}
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);
@@ -458,7 +464,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
private void ensureProfileUploaded() {
if (SignalStore.account().isRegistered() && !SignalStore.registrationValues().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
if (SignalStore.account().isRegistered() && !SignalStore.registration().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
AppDependencies.getJobManager().add(new ProfileUploadJob());
}

View File

@@ -28,11 +28,11 @@ import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar;
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarDrawable;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.FullscreenHelper;
@@ -91,16 +91,18 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
Recipient.live(recipientId).observe(this, recipient -> {
ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient)
: recipient.getContactPhoto();
FallbackContactPhoto fallbackPhoto = recipient.isSelf() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
: recipient.getFallbackContactPhoto();
FallbackAvatar fallbackAvatar = recipient.isSelf() ? new FallbackAvatar.Resource.Person(recipient.getAvatarColor())
: recipient.getFallbackAvatar();
Drawable fallbackDrawable = new FallbackAvatarDrawable(context, fallbackAvatar);
Resources resources = this.getResources();
Glide.with(this)
.asBitmap()
.load(contactPhoto)
.fallback(fallbackPhoto.asCallCard(this))
.error(fallbackPhoto.asCallCard(this))
.fallback(fallbackDrawable)
.error(fallbackDrawable)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.addListener(new RequestListener<Bitmap>() {
@Override

View File

@@ -112,6 +112,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
void onChangeProfileNameUpdateContact(@NonNull Recipient recipient);
void onCallToAction(@NonNull String action);
void onDonateClicked();
void onBlockJoinRequest(@NonNull Recipient recipient);

View File

@@ -35,6 +35,10 @@ class BiometricDeviceAuthentication(
private val DISALLOWED_BIOMETRIC_VERSIONS = setOf(28, 29)
}
fun canAuthenticate(): Boolean {
return biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS
}
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure

View File

@@ -29,14 +29,10 @@ import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.ContactFilterView;
import org.thoughtcrime.securesms.contacts.ContactSelectionDisplayMode;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DisplayMetricsUtil;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.lang.ref.WeakReference;

View File

@@ -337,7 +337,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
selectionLimit,
new ContactSearchAdapter.DisplayOptions(
isMulti,
ContactSearchAdapter.DisplaySmsTag.DEFAULT,
ContactSearchAdapter.DisplaySecondaryInformation.ALWAYS,
newCallCallback != null,
false

View File

@@ -20,6 +20,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
@@ -112,7 +113,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
switch (state) {
case NONE:
break;
case PROMPT_BATTERY_SAVER_DIALOG:
case PROMPT_SPECIFIC_BATTERY_SAVER_DIALOG:
DeviceSpecificNotificationBottomSheet.show(getSupportFragmentManager());
break;
case PROMPT_GENERAL_BATTERY_SAVER_DIALOG:
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
break;
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:

View File

@@ -44,10 +44,13 @@ import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.biometric.BiometricManager;
import androidx.biometric.BiometricPrompt;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
@@ -55,6 +58,7 @@ import org.thoughtcrime.securesms.components.AnimatingToggle;
import org.thoughtcrime.securesms.crypto.InvalidPassphraseException;
import org.thoughtcrime.securesms.crypto.MasterSecret;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.DynamicIntroTheme;
@@ -74,7 +78,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
private static final String TAG = Log.tag(PassphrasePromptActivity.class);
private static final short AUTHENTICATE_REQUEST_CODE = 1007;
private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown";
public static final String FROM_FOREGROUND = "from_foreground";
public static final String FROM_FOREGROUND = "from_foreground";
private static final int HELP_COUNT_THRESHOLD = 3;
private DynamicIntroTheme dynamicTheme = new DynamicIntroTheme();
private DynamicLanguage dynamicLanguage = new DynamicLanguage();
@@ -188,6 +193,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
} else {
Log.w(TAG, "Authentication failed");
hadFailure = true;
incrementAttemptCountAndShowHelpIfNecessary();
}
}
@@ -207,6 +213,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
passphraseText.setText("");
passphraseText.setError(
getString(R.string.PassphrasePromptActivity_invalid_passphrase_exclamation));
incrementAttemptCountAndShowHelpIfNecessary();
}
}
@@ -216,6 +223,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
MasterSecret masterSecret = MasterSecretUtil.getMasterSecret(this, MasterSecretUtil.UNENCRYPTED_PASSPHRASE);
setMasterSecret(masterSecret);
SignalStore.misc().setLockScreenAttemptCount(0);
} catch (InvalidPassphraseException e) {
throw new AssertionError(e);
}
@@ -272,6 +280,10 @@ public class PassphrasePromptActivity extends PassphraseActivity {
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
lockScreenButton.setOnClickListener(v -> resumeScreenLock(true));
if (SignalStore.misc().getLockScreenAttemptCount() > HELP_COUNT_THRESHOLD) {
showHelpDialogAndResetAttemptCount(null);
}
}
private void setLockTypeVisibility() {
@@ -288,6 +300,10 @@ public class PassphrasePromptActivity extends PassphraseActivity {
}
private void resumeScreenLock(boolean force) {
if (incrementAttemptCountAndShowHelpIfNecessary(() -> resumeScreenLock(force))) {
return;
}
if (!biometricAuth.authenticate(getApplicationContext(), force, this::showConfirmDeviceCredentialIntent)) {
handleAuthenticated();
}
@@ -312,6 +328,33 @@ public class PassphrasePromptActivity extends PassphraseActivity {
return Unit.INSTANCE;
}
private boolean incrementAttemptCountAndShowHelpIfNecessary() {
return incrementAttemptCountAndShowHelpIfNecessary(null);
}
private boolean incrementAttemptCountAndShowHelpIfNecessary(Runnable onDismissed) {
SignalStore.misc().incrementLockScreenAttemptCount();
if (SignalStore.misc().getLockScreenAttemptCount() > HELP_COUNT_THRESHOLD) {
showHelpDialogAndResetAttemptCount(onDismissed);
return true;
}
return false;
}
private void showHelpDialogAndResetAttemptCount(@Nullable Runnable onDismissed) {
new MaterialAlertDialogBuilder(this)
.setMessage(R.string.PassphrasePromptActivity_help_prompt_body)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
SignalStore.misc().setLockScreenAttemptCount(0);
if (onDismissed != null) {
onDismissed.run();
}
})
.show();
}
private class PassphraseActionListener implements TextView.OnEditorActionListener {
@Override
public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) {
@@ -366,6 +409,8 @@ public class PassphrasePromptActivity extends PassphraseActivity {
Log.w(TAG, "Authentication error: " + errorCode);
hadFailure = true;
incrementAttemptCountAndShowHelpIfNecessary();
if (errorCode != BiometricPrompt.ERROR_CANCELED && errorCode != BiometricPrompt.ERROR_USER_CANCELED) {
onAuthenticationFailed();
}

View File

@@ -16,7 +16,6 @@ import org.signal.core.util.logging.Log;
import org.signal.core.util.tracing.Tracer;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
import org.thoughtcrime.securesms.components.settings.app.changenumber.v2.ChangeNumberLockV2Activity;
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
@@ -28,12 +27,11 @@ import org.thoughtcrime.securesms.pin.PinRestoreActivity;
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.registration.ui.RegistrationActivity;
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.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.Locale;
@@ -55,7 +53,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 static final int STATE_TRANSFER_OR_RESTORE = 11;
private SignalServiceNetworkAccess networkAccess;
private BroadcastReceiver clearKeyReceiver;
@@ -153,7 +151,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();
case STATE_TRANSFER_OR_RESTORE: return getTransferOrRestoreIntent();
default: return null;
}
}
@@ -167,12 +165,12 @@ 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()) {
return STATE_CREATE_SIGNAL_PIN;
} else if (userCanTransferOrRestore()) {
return STATE_TRANSFER_OR_RESTORE;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
@@ -181,23 +179,27 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_TRANSFER_ONGOING;
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
return STATE_TRANSFER_LOCKED;
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class && getClass() != ChangeNumberLockV2Activity.class) {
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
return STATE_CHANGE_NUMBER_LOCK;
} else {
return STATE_NORMAL;
}
}
private boolean userCanTransferOrRestore() {
return !SignalStore.registration().isRegistrationComplete() && RemoteConfig.restoreAfterRegistration() && !SignalStore.registration().hasSkippedTransferOrRestore();
}
private boolean userMustCreateSignalPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut();
}
private boolean userHasSkippedOrForgottenPin() {
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped();
}
private boolean userMustSetProfileName() {
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
return !SignalStore.registration().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
}
private Intent getCreatePassphraseIntent() {
@@ -218,11 +220,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
if (FeatureFlags.registrationV2()) {
return RegistrationV2Activity.newIntentForNewRegistration(this, getIntent());
} else {
return RegistrationNavigationActivity.newIntentForNewRegistration(this, getIntent());
}
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
private Intent getEnterSignalPinIntent() {
@@ -241,8 +239,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return getRoutedIntent(CreateSvrPinActivity.class, intent);
}
private Intent getRestoreIntent() {
Intent intent = RestoreActivity.getIntentForRestore(this);
private Intent getTransferOrRestoreIntent() {
Intent intent = RestoreActivity.getIntentForTransferOrRestore(this);
return getRoutedIntent(intent, getIntent());
}
@@ -265,11 +263,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getChangeNumberLockIntent() {
if (FeatureFlags.registrationV2()) {
return ChangeNumberLockV2Activity.createIntent(this);
} else {
return ChangeNumberLockActivity.createIntent(this);
}
return ChangeNumberLockActivity.createIntent(this);
}
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {

View File

@@ -94,7 +94,7 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
@@ -415,7 +415,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
private void initializePendingParticipantFragmentListener() {
if (!FeatureFlags.adHocCalling()) {
if (!RemoteConfig.adHocCalling()) {
return;
}
@@ -822,7 +822,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
public void handleGroupMemberCountChange(int count) {
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize();
boolean canRing = count <= RemoteConfig.maxGroupCallRingSize();
callScreen.enableRingGroup(canRing);
AppDependencies.getSignalCallManager().setRingGroup(canRing);
}
@@ -1102,7 +1102,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void toggleControls() {
WebRtcControls controlState = viewModel.getWebRtcControls().getValue();
if (controlState != null && !controlState.displayIncomingCallButtons()) {
if (controlState != null && !controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) {
controlsAndInfo.toggleControls();
}
}
@@ -1287,9 +1287,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onHidden() {
fullscreenHelper.hideSystemUI();
if (videoTooltip != null) {
videoTooltip.dismiss();
WebRtcControls controlState = viewModel.getWebRtcControls().getValue();
if (controlState == null || !controlState.displayErrorControls()) {
fullscreenHelper.hideSystemUI();
if (videoTooltip != null) {
videoTooltip.dismiss();
}
}
}
}

View File

@@ -17,19 +17,19 @@ object SvrAuthTokens : AndroidBackupItem {
}
override fun getDataForBackup(): ByteArray {
val proto = SvrAuthToken(tokens = SignalStore.svr().authTokenList)
val proto = SvrAuthToken(svr2Tokens = SignalStore.svr.svr2AuthTokens)
return proto.encode()
}
override fun restoreData(data: ByteArray) {
if (SignalStore.svr().authTokenList.isNotEmpty()) {
if (SignalStore.svr.svr2AuthTokens.isNotEmpty()) {
return
}
try {
val proto = SvrAuthToken.ADAPTER.decode(data)
SignalStore.svr().putAuthTokenList(proto.tokens)
SignalStore.svr.putSvr2AuthTokens(proto.svr2Tokens)
} catch (e: IOException) {
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
}

View File

@@ -32,7 +32,7 @@ class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() {
}
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2)
if (downloadId != SignalStore.apkUpdate().downloadId) {
if (downloadId != SignalStore.apkUpdate.downloadId) {
Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.")
return
}

View File

@@ -38,30 +38,30 @@ object ApkUpdateInstaller {
* [userInitiated] = true, and then everything installs.
*/
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
if (downloadId != SignalStore.apkUpdate().downloadId) {
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate().downloadId})! We likely have newer data. Ignoring.")
if (downloadId != SignalStore.apkUpdate.downloadId) {
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate.downloadId})! We likely have newer data. Ignoring.")
ApkUpdateNotifications.dismissInstallPrompt(context)
AppDependencies.jobManager.add(ApkUpdateJob())
return
}
val digest = SignalStore.apkUpdate().digest
val digest = SignalStore.apkUpdate.digest
if (digest == null) {
Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate().clearDownloadAttributes()
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate().clearDownloadAttributes()
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppDependencies.appForegroundObserver.isForegrounded}, AutoUpdate=${SignalStore.apkUpdate().autoUpdate})")
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${AppDependencies.appForegroundObserver.isForegrounded}, AutoUpdate=${SignalStore.apkUpdate.autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
@@ -70,11 +70,11 @@ object ApkUpdateInstaller {
installApk(context, downloadId, userInitiated)
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate().clearDownloadAttributes()
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
} catch (e: SecurityException) {
Log.w(TAG, "Hit SecurityException when trying to install APK!", e)
SignalStore.apkUpdate().clearDownloadAttributes()
SignalStore.apkUpdate.clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
}
}
@@ -145,6 +145,6 @@ object ApkUpdateInstaller {
private fun shouldAutoUpdate(): Boolean {
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !AppDependencies.appForegroundObserver.isForegrounded
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate.autoUpdate && !AppDependencies.appForegroundObserver.isForegrounded
}
}

View File

@@ -36,9 +36,9 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
when (statusCode) {
PackageInstaller.STATUS_SUCCESS -> {
if (SignalStore.apkUpdate().lastApkUploadTime != SignalStore.apkUpdate().pendingApkUploadTime) {
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate().pendingApkUploadTime}")
SignalStore.apkUpdate().lastApkUploadTime = SignalStore.apkUpdate().pendingApkUploadTime
if (SignalStore.apkUpdate.lastApkUploadTime != SignalStore.apkUpdate.pendingApkUploadTime) {
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate.pendingApkUploadTime}")
SignalStore.apkUpdate.lastApkUploadTime = SignalStore.apkUpdate.pendingApkUploadTime
ApkUpdateNotifications.showAutoUpdateSuccess(context)
} else {
Log.i(TAG, "Spurious 'success' notification?")

View File

@@ -10,6 +10,8 @@ import android.os.Parcel
import org.signal.core.util.Base64
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.stickers.StickerLocator
import java.util.UUID
class ArchivedAttachment : Attachment {
@@ -44,8 +46,10 @@ class ArchivedAttachment : Attachment {
blurHash: String?,
voiceNote: Boolean,
borderless: Boolean,
stickerLocator: StickerLocator?,
gif: Boolean,
quote: Boolean
quote: Boolean,
uuid: UUID?
) : super(
contentType = contentType ?: "",
quote = quote,
@@ -66,10 +70,11 @@ class ArchivedAttachment : Attachment {
incrementalMacChunkSize = incrementalMacChunkSize ?: 0,
uploadTimestamp = 0,
caption = caption,
stickerLocator = null,
stickerLocator = stickerLocator,
blurHash = BlurHash.parseOrNull(blurHash),
audioHash = null,
transformProperties = null
transformProperties = null,
uuid = uuid
) {
this.archiveCdn = archiveCdn ?: Cdn.CDN_3.cdnNumber
this.archiveMediaName = archiveMediaName

View File

@@ -14,6 +14,8 @@ import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import org.whispersystems.signalservice.api.util.UuidUtil
import java.util.UUID
/**
* Note: We have to use our own Parcelable implementation because we need to do custom stuff to preserve
@@ -65,7 +67,9 @@ abstract class Attachment(
@JvmField
val audioHash: AudioHash?,
@JvmField
val transformProperties: TransformProperties?
val transformProperties: TransformProperties?,
@JvmField
val uuid: UUID?
) : Parcelable {
abstract val uri: Uri?
@@ -97,7 +101,8 @@ abstract class Attachment(
stickerLocator = ParcelCompat.readParcelable(parcel, StickerLocator::class.java.classLoader, StickerLocator::class.java),
blurHash = ParcelCompat.readParcelable(parcel, BlurHash::class.java.classLoader, BlurHash::class.java),
audioHash = ParcelCompat.readParcelable(parcel, AudioHash::class.java.classLoader, AudioHash::class.java),
transformProperties = ParcelCompat.readParcelable(parcel, TransformProperties::class.java.classLoader, TransformProperties::class.java)
transformProperties = ParcelCompat.readParcelable(parcel, TransformProperties::class.java.classLoader, TransformProperties::class.java),
uuid = UuidUtil.parseOrNull(parcel.readString())
)
override fun writeToParcel(dest: Parcel, flags: Int) {
@@ -125,6 +130,7 @@ abstract class Attachment(
dest.writeParcelable(blurHash, 0)
dest.writeParcelable(audioHash, 0)
dest.writeParcelable(transformProperties, 0)
dest.writeString(uuid?.toString())
}
override fun describeContents(): Int {

View File

@@ -55,6 +55,7 @@ object AttachmentUploadUtil {
.withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec))
.withCancelationSignal(cancellationSignal)
.withListener(progressListener)
.withUuid(attachment.uuid)
if (MediaUtil.isImageType(attachment.contentType)) {
builder.withBlurHash(getImageBlurHash(context, attachment))

View File

@@ -10,6 +10,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.ParcelUtil
import java.util.UUID
class DatabaseAttachment : Attachment {
@@ -79,7 +80,8 @@ class DatabaseAttachment : Attachment {
archiveThumbnailCdn: Int,
archiveMediaName: String?,
archiveMediaId: String?,
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState
thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState,
uuid: UUID?
) : super(
contentType = contentType!!,
transferState = transferProgress,
@@ -102,7 +104,8 @@ class DatabaseAttachment : Attachment {
stickerLocator = stickerLocator,
blurHash = blurHash,
audioHash = audioHash,
transformProperties = transformProperties
transformProperties = transformProperties,
uuid = uuid
) {
this.attachmentId = attachmentId
this.mmsId = mmsId

View File

@@ -12,6 +12,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil
import org.whispersystems.signalservice.internal.push.DataMessage
import java.util.Optional
import java.util.UUID
class PointerAttachment : Attachment {
@VisibleForTesting
@@ -35,7 +36,8 @@ class PointerAttachment : Attachment {
uploadTimestamp: Long,
caption: String?,
stickerLocator: StickerLocator?,
blurHash: BlurHash?
blurHash: BlurHash?,
uuid: UUID?
) : super(
contentType = contentType,
transferState = transferState,
@@ -59,7 +61,8 @@ class PointerAttachment : Attachment {
stickerLocator = stickerLocator,
blurHash = blurHash,
audioHash = null,
transformProperties = null
transformProperties = null,
uuid = uuid
)
constructor(parcel: Parcel) : super(parcel)
@@ -115,7 +118,8 @@ class PointerAttachment : Attachment {
uploadTimestamp = pointer.get().asPointer().uploadTimestamp,
caption = pointer.get().asPointer().caption.orElse(null),
stickerLocator = stickerLocator,
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null))
blurHash = BlurHash.parseOrNull(pointer.get().asPointer().blurHash.orElse(null)),
uuid = pointer.get().asPointer().uuid
)
)
}
@@ -152,7 +156,8 @@ class PointerAttachment : Attachment {
uploadTimestamp = thumbnail?.asPointer()?.uploadTimestamp ?: 0,
caption = thumbnail?.asPointer()?.caption?.orElse(null),
stickerLocator = null,
blurHash = null
blurHash = null,
uuid = thumbnail?.asPointer()?.uuid
)
)
}

View File

@@ -4,6 +4,7 @@ import android.net.Uri
import android.os.Parcel
import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable
import java.util.UUID
/**
* An attachment that represents where an attachment used to be. Useful when you need to know that
@@ -35,7 +36,8 @@ class TombstoneAttachment : Attachment {
stickerLocator = null,
blurHash = null,
audioHash = null,
transformProperties = null
transformProperties = null,
uuid = null
)
constructor(
@@ -49,7 +51,8 @@ class TombstoneAttachment : Attachment {
voiceNote: Boolean = false,
borderless: Boolean = false,
gif: Boolean = false,
quote: Boolean
quote: Boolean,
uuid: UUID?
) : super(
contentType = contentType ?: "",
quote = quote,
@@ -73,7 +76,8 @@ class TombstoneAttachment : Attachment {
stickerLocator = null,
blurHash = BlurHash.parseOrNull(blurHash),
audioHash = null,
transformProperties = null
transformProperties = null,
uuid = uuid
)
constructor(parcel: Parcel) : super(parcel)

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.blurhash.BlurHash
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties
import org.thoughtcrime.securesms.stickers.StickerLocator
import java.util.Objects
import java.util.UUID
class UriAttachment : Attachment {
@@ -46,6 +47,7 @@ class UriAttachment : Attachment {
transformProperties = transformProperties
)
@JvmOverloads
constructor(
dataUri: Uri,
contentType: String,
@@ -63,7 +65,8 @@ class UriAttachment : Attachment {
stickerLocator: StickerLocator?,
blurHash: BlurHash?,
audioHash: AudioHash?,
transformProperties: TransformProperties?
transformProperties: TransformProperties?,
uuid: UUID? = UUID.randomUUID()
) : super(
contentType = contentType,
transferState = transferState,
@@ -87,7 +90,8 @@ class UriAttachment : Attachment {
stickerLocator = stickerLocator,
blurHash = blurHash,
audioHash = audioHash,
transformProperties = transformProperties
transformProperties = transformProperties,
uuid = uuid
) {
uri = Objects.requireNonNull(dataUri)
}

View File

@@ -134,6 +134,6 @@ object AvatarRenderer {
}
private fun createMedia(uri: Uri, size: Long): Media {
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty())
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty())
}
}

View File

@@ -0,0 +1,158 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.avatar.fallback
import androidx.annotation.DrawableRes
import androidx.annotation.Px
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.util.NameUtil
/**
* Specifies what kind of avatar should be generated for a given recipient.
*/
sealed interface FallbackAvatar {
val color: AvatarColor
/**
* Transparent avatar
*/
data object Transparent : FallbackAvatar {
override val color: AvatarColor = AvatarColor.UNKNOWN
}
/**
* Generated avatars utilize the initials of the given recipient
*/
data class Text(val content: String, override val color: AvatarColor) : FallbackAvatar {
init {
check(content.isNotEmpty())
}
}
/**
* Fallback avatars that are backed by resources.
*/
sealed interface Resource : FallbackAvatar {
@DrawableRes
fun getIconBySize(size: Size): Int
/**
* Local user
*/
data class Local(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_note_compact_16
Size.MEDIUM -> R.drawable.symbol_note_24
Size.LARGE -> R.drawable.symbol_note_display_bold_40
}
}
}
/**
* Individual user without a display name.
*/
data class Person(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_person_compact_16
Size.MEDIUM -> R.drawable.symbol_person_24
Size.LARGE -> R.drawable.symbol_person_display_bold_40
}
}
}
/**
* A group
*/
data class Group(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_group_compact_16
Size.MEDIUM -> R.drawable.symbol_group_24
Size.LARGE -> R.drawable.symbol_group_display_bold_40
}
}
}
/**
* Story distribution lists
*/
data class DistributionList(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_stories_compact_16
Size.MEDIUM -> R.drawable.symbol_stories_24
Size.LARGE -> R.drawable.symbol_stories_display_bold_40
}
}
}
/**
* Call Links
*/
data class CallLink(override val color: AvatarColor) : Resource {
override fun getIconBySize(size: Size): Int {
return when (size) {
Size.SMALL -> R.drawable.symbol_video_compact_16
Size.MEDIUM -> R.drawable.symbol_video_24
Size.LARGE -> R.drawable.symbol_video_display_bold_40
}
}
}
}
enum class Size {
/**
* Smaller than 32dp
*/
SMALL,
/**
* 32dp and larger
*/
MEDIUM,
/**
* 80dp and larger
*/
LARGE
}
companion object {
const val ICON_TO_BACKGROUND_SCALE = 0.625
@JvmStatic
@JvmOverloads
fun forTextOrDefault(text: String, avatarColor: AvatarColor, default: FallbackAvatar = Resource.Person(avatarColor)): FallbackAvatar {
val abbreviation = NameUtil.getAbbreviation(text)
return if (abbreviation != null) {
Text(abbreviation, avatarColor)
} else {
default
}
}
fun getSizeByPx(@Px px: Int): Size {
return getSizeByDp(DimensionUnit.PIXELS.toDp(px.toFloat()).dp)
}
fun getSizeByDp(dp: Dp): Size {
val rawDp = dp.value
return when {
rawDp >= 80.0 -> Size.LARGE
rawDp < 32.0 -> Size.SMALL
else -> Size.MEDIUM
}
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.avatar.fallback
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import androidx.core.content.ContextCompat
import com.airbnb.lottie.SimpleColorFilter
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.RelativeCornerSize
import com.google.android.material.shape.RoundedCornerTreatment
import com.google.android.material.shape.ShapeAppearanceModel
import org.thoughtcrime.securesms.avatar.Avatar
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.avatar.TextAvatarDrawable
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
class FallbackAvatarDrawable(
private val context: Context,
private val fallbackAvatar: FallbackAvatar
) : MaterialShapeDrawable() {
private val avatarColorPair: AvatarColorPair = AvatarColorPair.create(context, fallbackAvatar.color)
private var avatarSize: FallbackAvatar.Size = FallbackAvatar.Size.SMALL
private var icon: Drawable? = null
init {
fillColor = ColorStateList.valueOf(avatarColorPair.backgroundColor)
}
fun circleCrop(): FallbackAvatarDrawable {
shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCorners(RoundedCornerTreatment())
.setAllCornerSizes(RelativeCornerSize(0.5f))
.build()
return this
}
override fun onBoundsChange(bounds: Rect) {
super.onBoundsChange(bounds)
avatarSize = FallbackAvatar.getSizeByPx(bounds.width())
icon = when (fallbackAvatar) {
is FallbackAvatar.Resource -> {
val resourceIcon = ContextCompat.getDrawable(context, fallbackAvatar.getIconBySize(avatarSize))!!
val iconBounds = Rect(bounds)
iconBounds.inset(
((bounds.width() - (bounds.width() * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2f).toInt(),
((bounds.height() - (bounds.height() * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2f).toInt()
)
resourceIcon.bounds = iconBounds
resourceIcon
}
is FallbackAvatar.Text -> TextAvatarDrawable(
context = context,
avatar = Avatar.Text(
fallbackAvatar.content,
Avatars.ColorPair(avatarColorPair.backgroundColor, avatarColorPair.foregroundColor, ""),
Avatar.DatabaseId.DoNotPersist
),
size = bounds.width()
)
FallbackAvatar.Transparent -> null
}
icon?.alpha = alpha
icon?.colorFilter = SimpleColorFilter(avatarColorPair.foregroundColor)
}
override fun draw(canvas: Canvas) {
if (icon == null) return
super.draw(canvas)
icon?.draw(canvas)
}
override fun setAlpha(alpha: Int) {
super.setAlpha(alpha)
icon?.alpha = alpha
invalidateSelf()
}
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.avatar.fallback
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.avatar.AvatarRenderer
import org.thoughtcrime.securesms.avatar.Avatars
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair
@Composable
fun FallbackAvatarImage(
fallbackAvatar: FallbackAvatar,
modifier: Modifier = Modifier,
shape: Shape = CircleShape
) {
if (fallbackAvatar is FallbackAvatar.Transparent) {
Box(modifier = modifier)
return
}
val context = LocalContext.current
val colorPair = remember(fallbackAvatar) {
AvatarColorPair.create(context, fallbackAvatar.color)
}
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = modifier
.background(Color(colorPair.backgroundColor), shape)
) {
when (fallbackAvatar) {
is FallbackAvatar.Resource -> {
val size = remember(maxWidth) {
FallbackAvatar.getSizeByDp(maxWidth)
}
val padding = remember(maxWidth) {
((maxWidth.value - (maxWidth.value * FallbackAvatar.ICON_TO_BACKGROUND_SCALE)) / 2).dp
}
Icon(
painter = painterResource(fallbackAvatar.getIconBySize(size)),
contentDescription = null,
tint = Color(colorPair.foregroundColor),
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
}
is FallbackAvatar.Text -> {
val size = DimensionUnit.DP.toPixels(maxWidth.value) * 0.8f
val textSize = DimensionUnit.PIXELS.toDp(Avatars.getTextSizeForLength(context, fallbackAvatar.content, size, size))
// TODO [alex] -- Handle emoji
Text(
text = fallbackAvatar.content,
color = Color(colorPair.foregroundColor),
fontSize = TextUnit(textSize, TextUnitType.Sp),
fontFamily = FontFamily(AvatarRenderer.getTypeface(context))
)
}
FallbackAvatar.Transparent -> {}
}
}
}
@SignalPreview
@Composable
fun FallbackAvatarImagePreview() {
Previews.Preview {
Column {
Text(text = "Compose - Large")
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
modifier = Modifier.size(160.dp)
)
Text(text = "Compose - Medium")
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
modifier = Modifier.size(64.dp)
)
Text(text = "Compose - Small")
FallbackAvatarImage(
fallbackAvatar = FallbackAvatar.Text("AE", AvatarColor.A100),
modifier = Modifier.size(24.dp)
)
}
}
}

View File

@@ -87,8 +87,8 @@ class AvatarView @JvmOverloads constructor(
avatar.setRecipient(recipient)
}
fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
fun setFallbackAvatarProvider(fallbackAvatarProvider: AvatarImageView.FallbackAvatarProvider?) {
avatar.setFallbackAvatarProvider(fallbackAvatarProvider)
}
fun disableQuickContact() {

View File

@@ -28,7 +28,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment;
import org.thoughtcrime.securesms.restore.restorelocalbackup.PassphraseAsYouTypeFormatter;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.util.BackupUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
@@ -170,7 +170,7 @@ public class BackupDialog {
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
positiveButton.setEnabled(false);
RestoreBackupFragment.PassphraseAsYouTypeFormatter formatter = new RestoreBackupFragment.PassphraseAsYouTypeFormatter();
PassphraseAsYouTypeFormatter formatter = new PassphraseAsYouTypeFormatter();
prompt.addTextChangedListener(new AfterTextChanged(editable -> {
formatter.afterTextChanged(editable);

View File

@@ -5,16 +5,24 @@
package org.thoughtcrime.securesms.backup.v2
import androidx.annotation.WorkerThread
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer
import org.signal.core.util.LongSerializer
import org.signal.core.util.fullWalCheckpoint
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.withinTransaction
import org.signal.libsignal.messagebackup.MessageBackup
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.backups.BackupLevel
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
@@ -26,18 +34,29 @@ 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
import org.thoughtcrime.securesms.backup.v2.processor.StickerBackupProcessor
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
import org.thoughtcrime.securesms.backup.v2.stream.BackupExportWriter
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupWriter
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.PlainTextBackupWriter
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.components.settings.app.subscription.RecurringInAppPaymentRepository
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.KeyValueDatabase
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.toMillis
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.StatusCodeErrorAction
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
@@ -53,84 +72,181 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.Pro
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.SubscriptionsConfiguration
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.math.BigDecimal
import java.time.ZonedDateTime
import java.util.Currency
import java.util.Locale
import kotlin.time.Duration.Companion.milliseconds
object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
private const val VERSION = 1L
private const val MAIN_DB_SNAPSHOT_NAME = "signal-snapshot.db"
private const val KEYVALUE_DB_SNAPSHOT_NAME = "key-value-snapshot.db"
private val resetInitializedStateErrorAction: StatusCodeErrorAction = { error ->
when (error.code) {
401 -> {
Log.i(TAG, "Resetting initialized state due to 401.")
SignalStore.backup().backupsInitialized = false
SignalStore.backup.backupsInitialized = false
}
403 -> {
Log.i(TAG, "Bad auth credential. Clearing stored credentials.")
SignalStore.backup().clearAllCredentials()
SignalStore.backup.clearAllCredentials()
}
}
}
@WorkerThread
fun turnOffAndDeleteBackup() {
RecurringInAppPaymentRepository.cancelActiveSubscriptionSync(InAppPaymentSubscriberRecord.Type.BACKUP)
SignalStore.backup.areBackupsEnabled = false
SignalStore.backup.backupTier = null
}
private fun createSignalDatabaseSnapshot(): SignalDatabase {
// Need to do a WAL checkpoint to ensure that the database file we're copying has all pending writes
if (!SignalDatabase.rawDatabase.fullWalCheckpoint()) {
Log.w(TAG, "Failed to checkpoint WAL for main database! Not guaranteed to be using the most recent data.")
}
// We make a copy of the database within a transaction to ensure that no writes occur while we're copying the file
return SignalDatabase.rawDatabase.withinTransaction {
val context = AppDependencies.application
val existingDbFile = context.getDatabasePath(SignalDatabase.DATABASE_NAME)
val targetFile = File(existingDbFile.parentFile, MAIN_DB_SNAPSHOT_NAME)
try {
existingDbFile.copyTo(targetFile, overwrite = true)
} catch (e: IOException) {
// TODO [backup] Gracefully handle this error
throw IllegalStateException("Failed to copy database file!", e)
}
SignalDatabase(
context = context,
databaseSecret = DatabaseSecretProvider.getOrCreateDatabaseSecret(context),
attachmentSecret = AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
name = MAIN_DB_SNAPSHOT_NAME
)
}
}
private fun createSignalStoreSnapshot(): SignalStore {
val context = AppDependencies.application
// Need to do a WAL checkpoint to ensure that the database file we're copying has all pending writes
if (!KeyValueDatabase.getInstance(context).writableDatabase.fullWalCheckpoint()) {
Log.w(TAG, "Failed to checkpoint WAL for KeyValueDatabase! Not guaranteed to be using the most recent data.")
}
// We make a copy of the database within a transaction to ensure that no writes occur while we're copying the file
return KeyValueDatabase.getInstance(context).writableDatabase.withinTransaction {
val existingDbFile = context.getDatabasePath(KeyValueDatabase.DATABASE_NAME)
val targetFile = File(existingDbFile.parentFile, KEYVALUE_DB_SNAPSHOT_NAME)
try {
existingDbFile.copyTo(targetFile, overwrite = true)
} catch (e: IOException) {
// TODO [backup] Gracefully handle this error
throw IllegalStateException("Failed to copy database file!", e)
}
val db = KeyValueDatabase.createWithName(context, KEYVALUE_DB_SNAPSHOT_NAME)
SignalStore(KeyValueStore(db))
}
}
private fun deleteDatabaseSnapshot() {
val targetFile = AppDependencies.application.getDatabasePath(MAIN_DB_SNAPSHOT_NAME)
if (!targetFile.delete()) {
Log.w(TAG, "Failed to delete main database snapshot!")
}
}
private fun deleteSignalStoreSnapshot() {
val targetFile = AppDependencies.application.getDatabasePath(KEYVALUE_DB_SNAPSHOT_NAME)
if (!targetFile.delete()) {
Log.w(TAG, "Failed to delete key value database snapshot!")
}
}
fun export(outputStream: OutputStream, append: (ByteArray) -> Unit, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = append
)
}
val dbSnapshot: SignalDatabase = createSignalDatabaseSnapshot()
val signalStoreSnapshot: SignalStore = createSignalStoreSnapshot()
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = true)
writer.use {
writer.write(
BackupInfo(
version = VERSION,
backupTimeMs = exportState.backupTime
try {
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
EncryptedBackupWriter(
key = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account.aci!!,
outputStream = outputStream,
append = append
)
)
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
AccountDataProcessor.export {
writer.write(it)
eventTimer.emit("account")
}
}
RecipientBackupProcessor.export(exportState) {
writer.write(it)
eventTimer.emit("recipient")
}
val exportState = ExportState(backupTime = System.currentTimeMillis(), allowMediaBackup = SignalStore.backup.backsUpMedia)
ChatBackupProcessor.export(exportState) { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
writer.use {
writer.write(
BackupInfo(
version = VERSION,
backupTimeMs = exportState.backupTime
)
)
// Note: Without a transaction, we may export inconsistent state. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
dbSnapshot.rawWritableDatabase.withinTransaction {
AccountDataProcessor.export(dbSnapshot, signalStoreSnapshot) {
writer.write(it)
eventTimer.emit("account")
}
AdHocCallBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("call")
}
RecipientBackupProcessor.export(dbSnapshot, signalStoreSnapshot, exportState) {
writer.write(it)
eventTimer.emit("recipient")
}
ChatItemBackupProcessor.export(exportState) { frame ->
writer.write(frame)
eventTimer.emit("message")
ChatBackupProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
AdHocCallBackupProcessor.export(dbSnapshot) { frame ->
writer.write(frame)
eventTimer.emit("call")
}
StickerBackupProcessor.export(dbSnapshot) { frame ->
writer.write(frame)
eventTimer.emit("sticker-pack")
}
ChatItemBackupProcessor.export(dbSnapshot, exportState) { frame ->
writer.write(frame)
eventTimer.emit("message")
}
}
}
}
Log.d(TAG, "export() ${eventTimer.stop().summary}")
Log.d(TAG, "export() ${eventTimer.stop().summary}")
} finally {
deleteDatabaseSnapshot()
deleteSignalStoreSnapshot()
}
}
fun export(plaintext: Boolean = false): ByteArray {
@@ -140,7 +256,7 @@ object BackupRepository {
}
fun validate(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData): ValidationResult {
val masterKey = SignalStore.svr().getOrCreateMasterKey()
val masterKey = SignalStore.svr.getOrCreateMasterKey()
val key = MessageBackupKey(masterKey.serialize(), Aci.parseFromBinary(selfData.aci.toByteArray()))
return MessageBackup.validate(key, MessageBackup.Purpose.REMOTE_BACKUP, inputStreamFactory, length)
@@ -149,15 +265,15 @@ object BackupRepository {
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val frameReader = if (plaintext) {
PlainTextBackupReader(inputStreamFactory())
PlainTextBackupReader(inputStreamFactory(), length)
} else {
EncryptedBackupReader(
key = backupKey,
aci = selfData.aci,
streamLength = length,
length = length,
dataStream = inputStreamFactory
)
}
@@ -180,16 +296,21 @@ object BackupRepository {
SignalDatabase.threads.clearAllDataForBackupRestore()
SignalDatabase.messages.clearAllDataForBackupRestore()
SignalDatabase.attachments.clearAllDataForBackupRestore()
SignalDatabase.stickers.clearAllDataForBackupRestore()
// Add back self after clearing data
val selfId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(selfData.aci, selfData.pni, selfData.e164, pniVerified = true, changeSelf = true)
SignalDatabase.recipients.setProfileKey(selfId, selfData.profileKey)
SignalDatabase.recipients.setProfileSharing(selfId, true)
// Add back my story after clearing data
DistributionListTables.insertInitialDistributionListAtCreationTime(it)
eventTimer.emit("setup")
val backupState = BackupState(backupKey)
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
val totalLength = frameReader.getStreamLength()
for (frame in frameReader) {
when {
frame.account != null -> {
@@ -212,6 +333,11 @@ object BackupRepository {
eventTimer.emit("call")
}
frame.stickerPack != null -> {
StickerBackupProcessor.import(frame.stickerPack)
eventTimer.emit("sticker-pack")
}
frame.chatItem != null -> {
chatItemInserter.insert(frame.chatItem)
eventTimer.emit("chatItem")
@@ -220,6 +346,7 @@ object BackupRepository {
else -> Log.w(TAG, "Unrecognized frame")
}
EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength))
}
if (chatItemInserter.flush()) {
@@ -244,7 +371,7 @@ object BackupRepository {
fun listRemoteMediaObjects(limit: Int, cursor: String? = null): NetworkResult<ArchiveGetMediaItemsResponse> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -254,7 +381,7 @@ object BackupRepository {
fun getRemoteBackupUsedSpace(): NetworkResult<Long?> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -263,12 +390,27 @@ object BackupRepository {
}
}
private fun getBackupTier(): NetworkResult<MessageBackupTier> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.map { credential ->
val zkCredential = api.getZkCredential(backupKey, credential)
if (zkCredential.backupLevel == BackupLevel.MEDIA) {
MessageBackupTier.PAID
} else {
MessageBackupTier.FREE
}
}
}
/**
* Returns an object with details about the remote backup state.
*/
fun getRemoteBackupState(): NetworkResult<BackupMetadata> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -295,7 +437,7 @@ object BackupRepository {
*/
fun uploadBackupFile(backupStream: InputStream, backupStreamLength: Long): Boolean {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -317,7 +459,7 @@ object BackupRepository {
fun downloadBackupFile(destination: File, listener: ProgressListener? = null): Boolean {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -331,12 +473,30 @@ object BackupRepository {
} is NetworkResult.Success
}
fun getBackupFileLastModified(): NetworkResult<ZonedDateTime?> {
val api = AppDependencies.signalServiceAccountManager.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 } }
.then { pair ->
val (cdnCredentials, info) = pair
val messageReceiver = AppDependencies.signalServiceMessageReceiver
NetworkResult.fromFetch {
messageReceiver.getCdnLastModifiedTime(info.cdn!!, cdnCredentials, "backups/${info.backupDir}/${info.backupName}")
}
}
}
/**
* Returns an object with details about the remote backup state.
*/
fun debugGetArchivedMediaState(): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -349,7 +509,7 @@ object BackupRepository {
*/
fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult<ResumableUploadSpec> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -362,7 +522,7 @@ object BackupRepository {
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
return initBackupAndFetchAuth(backupKey)
@@ -377,7 +537,7 @@ object BackupRepository {
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -400,7 +560,7 @@ object BackupRepository {
fun archiveMedia(databaseAttachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResult> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -440,7 +600,7 @@ object BackupRepository {
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = attachments
.filter { it.archiveMediaId != null }
@@ -472,7 +632,7 @@ object BackupRepository {
fun deleteAbandonedMediaObjects(mediaObjects: Collection<ArchivedMediaObject>): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
val mediaToDelete = mediaObjects
.map {
@@ -500,7 +660,7 @@ object BackupRepository {
fun debugDeleteAllArchivedMedia(): NetworkResult<Unit> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return debugGetArchivedMediaState()
.then { archivedMedia ->
@@ -536,13 +696,13 @@ object BackupRepository {
* Retrieve credentials for reading from the backup cdn.
*/
fun getCdnReadCredentials(cdnNumber: Int): NetworkResult<GetArchiveCdnCredentialsResponse> {
val cached = SignalStore.backup().cdnReadCredentials
val cached = SignalStore.backup.cdnReadCredentials
if (cached != null) {
return NetworkResult.Success(cached)
}
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
@@ -554,20 +714,41 @@ object BackupRepository {
}
.also {
if (it is NetworkResult.Success) {
SignalStore.backup().cdnReadCredentials = it.result
SignalStore.backup.cdnReadCredentials = it.result
}
}
.also { Log.i(TAG, "getCdnReadCredentialsResult: $it") }
}
fun restoreBackupTier(): MessageBackupTier? {
// TODO: more complete error handling
try {
val lastModified = getBackupFileLastModified().successOrThrow()
if (lastModified != null) {
SignalStore.backup.lastBackupTime = lastModified.toMillis()
}
} catch (e: Exception) {
Log.i(TAG, "Could not check for backup file.", e)
SignalStore.backup.backupTier = null
return null
}
SignalStore.backup.backupTier = try {
getBackupTier().successOrThrow()
} catch (e: Exception) {
Log.i(TAG, "Could not retrieve backup tier.", e)
null
}
return SignalStore.backup.backupTier
}
/**
* 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
val cachedBackupDirectory = SignalStore.backup.cachedBackupDirectory
val cachedBackupMediaDirectory = SignalStore.backup.cachedBackupMediaDirectory
if (cachedBackupDirectory != null && cachedBackupMediaDirectory != null) {
return NetworkResult.Success(
@@ -579,23 +760,99 @@ object BackupRepository {
}
val api = AppDependencies.signalServiceAccountManager.archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey()
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.getBackupInfo(backupKey, credential).map {
SignalStore.backup().usedBackupMediaSpace = it.usedSpace ?: 0L
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
SignalStore.backup.cachedBackupDirectory = it.result.backupDir
SignalStore.backup.cachedBackupMediaDirectory = it.result.mediaDir
}
}
}
suspend fun getAvailableBackupsTypes(availableBackupTiers: List<MessageBackupTier>): List<MessageBackupsType> {
return availableBackupTiers.map { getBackupsType(it) }
}
suspend fun getBackupsType(tier: MessageBackupTier): MessageBackupsType {
val backupCurrency = SignalStore.inAppPayments.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.BACKUP)
return when (tier) {
MessageBackupTier.FREE -> getFreeType(backupCurrency)
MessageBackupTier.PAID -> getPaidType(backupCurrency)
}
}
private fun getFreeType(currency: Currency): MessageBackupsType {
return MessageBackupsType(
tier = MessageBackupTier.FREE,
pricePerMonth = FiatMoney(BigDecimal.ZERO, currency),
title = "Text + 30 days of media", // TODO [message-backups] Finalize text (does this come from server?)
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Last 30 days of media" // TODO [message-backups] Finalize text (does this come from server?)
)
)
)
}
private suspend fun getPaidType(currency: Currency): MessageBackupsType {
val serviceResponse = withContext(Dispatchers.IO) {
AppDependencies
.donationsService
.getDonationsConfiguration(Locale.getDefault())
}
if (serviceResponse.result.isEmpty) {
if (serviceResponse.applicationError.isPresent) {
throw serviceResponse.applicationError.get()
}
if (serviceResponse.executionError.isPresent) {
throw serviceResponse.executionError.get()
}
error("Unhandled error occurred while downloading configuration.")
}
val config = serviceResponse.result.get()
return MessageBackupsType(
tier = MessageBackupTier.PAID,
pricePerMonth = FiatMoney(config.currencies[currency.currencyCode.lowercase()]!!.backupSubscription[SubscriptionsConfiguration.BACKUPS_LEVEL]!!, currency),
title = "Text + All your media", // TODO [message-backups] Finalize text (does this come from server?)
features = persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "Full text message backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_album_compact_bold_16,
label = "Full media backup" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = "1TB of storage (~250K photos)" // TODO [message-backups] Finalize text (does this come from server?)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_heart_compact_bold_16,
label = "Thanks for supporting Signal!" // TODO [message-backups] Finalize text (does this come from server?)
)
)
)
}
/**
* 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.
@@ -603,14 +860,14 @@ object BackupRepository {
private fun initBackupAndFetchAuth(backupKey: BackupKey): NetworkResult<ArchiveServiceCredential> {
val api = AppDependencies.signalServiceAccountManager.archiveApi
return if (SignalStore.backup().backupsInitialized) {
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 }
.runIfSuccessful { SignalStore.backup.backupsInitialized = true }
.runOnStatusCodeError(resetInitializedStateErrorAction)
}
}
@@ -621,7 +878,7 @@ object BackupRepository {
private fun getAuthCredential(): NetworkResult<ArchiveServiceCredential> {
val currentTime = System.currentTimeMillis()
val credential = SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)
val credential = SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)
if (credential != null) {
return NetworkResult.Success(credential)
@@ -630,9 +887,9 @@ object BackupRepository {
Log.w(TAG, "No credentials found for today, need to fetch new ones! This shouldn't happen under normal circumstances. We should ensure the routine fetch is running properly.")
return AppDependencies.signalServiceAccountManager.archiveApi.getServiceCredentials(currentTime).map { result ->
SignalStore.backup().addCredentials(result.credentials.toList())
SignalStore.backup().clearCredentialsOlderThan(currentTime)
SignalStore.backup().credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
SignalStore.backup.addCredentials(result.credentials.toList())
SignalStore.backup.clearCredentialsOlderThan(currentTime)
SignalStore.backup.credentialsByDay.getForCurrentTime(currentTime.milliseconds)!!
}
}
@@ -688,18 +945,3 @@ class BackupMetadata(
val usedSpace: Long,
val mediaCount: Long
)
enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
companion object Serializer : LongSerializer<MessageBackupTier> {
override fun serialize(data: MessageBackupTier): Long {
return data.value.toLong()
}
override fun deserialize(data: Long): MessageBackupTier {
return values().firstOrNull { it.value == data.toInt() } ?: FREE
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.LongSerializer
/**
* Serializable enum value for what we think a user's current backup tier is.
*
* We should not trust the stored value on its own, we should also verify it
* against what the server knows, but it is a useful flag that helps avoid a
* network call in some cases.
*/
enum class MessageBackupTier(val value: Int) {
FREE(0),
PAID(1);
companion object Serializer : LongSerializer<MessageBackupTier?> {
override fun serialize(data: MessageBackupTier?): Long {
return data?.value?.toLong() ?: -1
}
override fun deserialize(data: Long): MessageBackupTier? {
return entries.firstOrNull { it.value == data.toInt() }
}
}
}

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) {
enum class Type {
PROGRESS_DOWNLOAD,
PROGRESS_RESTORE,
PROGRESS_MEDIA_RESTORE,
FINISHED
}
fun getProgress(): Float {
if (estimatedTotalCount == 0L) {
return 0f
}
return count.toFloat() / estimatedTotalCount.toFloat()
}
}

View File

@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.select
import org.signal.ringrtc.CallLinkRootKey
@@ -30,11 +31,17 @@ fun CallLinkTable.getCallLinksForBackup(): BackupCallLinkIterator {
return BackupCallLinkIterator(cursor)
}
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId {
fun CallLinkTable.restoreFromBackup(callLink: CallLink): RecipientId? {
val rootKey: CallLinkRootKey
try {
rootKey = CallLinkRootKey(callLink.rootKey.toByteArray())
} catch (e: Exception) {
return null
}
return SignalDatabase.callLinks.insertCallLink(
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(callLink.rootKey.toByteArray())),
roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey),
credentials = CallLinkCredentials(callLink.rootKey.toByteArray(), callLink.adminKey?.toByteArray()),
state = SignalCallLinkState(
name = callLink.name,
@@ -63,10 +70,12 @@ class BackupCallLinkIterator(private val cursor: Cursor) : Iterator<BackupRecipi
return BackupRecipient(
id = callLink.recipientId.toLong(),
callLink = CallLink(
rootKey = callLink.credentials!!.linkKeyBytes.toByteString(),
adminKey = callLink.credentials.adminPassBytes?.toByteString(),
rootKey = callLink.credentials?.linkKeyBytes?.toByteString() ?: ByteString.EMPTY,
adminKey = callLink.credentials?.adminPassBytes?.toByteString(),
name = callLink.state.name,
expirationMs = callLink.state.expiration.toEpochMilli(),
expirationMs = try {
callLink.state.expiration.toEpochMilli()
} catch (e: ArithmeticException) { Long.MAX_VALUE },
restrictions = callLink.state.restrictions.toBackup()
)
)

View File

@@ -38,7 +38,7 @@ fun CallTable.restoreCallLogFromBackup(call: AdHocCall, backupState: BackupState
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.startedCallTimestamp
CallTable.TIMESTAMP to call.callTimestamp
)
writableDatabase.insert(CallTable.TABLE_NAME, SQLiteDatabase.CONFLICT_IGNORE, values)
@@ -64,7 +64,7 @@ class CallLogIterator(private val cursor: Cursor) : Iterator<AdHocCall?>, Closea
callId = callId,
recipientId = cursor.requireLong(CallTable.PEER),
state = AdHocCall.State.GENERIC,
startedCallTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
callTimestamp = cursor.requireLong(CallTable.TIMESTAMP)
)
}

View File

@@ -7,21 +7,26 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.json.JSONArray
import org.json.JSONException
import org.signal.core.util.Base64
import org.signal.core.util.Base64.decode
import org.signal.core.util.Base64.decodeOrThrow
import org.signal.core.util.Hex
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireLongOrNull
import org.signal.core.util.requireString
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
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.ContactAttachment
import org.thoughtcrime.securesms.backup.v2.proto.ContactMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
@@ -37,8 +42,11 @@ import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SessionSwitchoverChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.backup.v2.proto.Sticker
import org.thoughtcrime.securesms.backup.v2.proto.StickerMessage
import org.thoughtcrime.securesms.backup.v2.proto.Text
import org.thoughtcrime.securesms.backup.v2.proto.ThreadMergeChatUpdate
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
@@ -56,11 +64,13 @@ import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.payments.FailureReason
import org.thoughtcrime.securesms.payments.State
@@ -76,6 +86,7 @@ import java.util.LinkedList
import java.util.Queue
import kotlin.jvm.optionals.getOrNull
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
/**
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
@@ -84,7 +95,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, private val archiveMedia: Boolean) : 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)
@@ -104,7 +115,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast)
}
override fun next(): ChatItem {
override fun next(): ChatItem? {
if (buffer.isNotEmpty()) {
return buffer.remove()
}
@@ -129,227 +140,89 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val builder = record.toBasicChatItemBuilder(groupReceiptsById[id])
when {
record.remoteDeleted -> builder.remoteDeletedMessage = RemoteDeletedMessage()
MessageTypes.isJoinedType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.JOINED_SIGNAL))
MessageTypes.isIdentityUpdate(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_UPDATE))
MessageTypes.isIdentityVerified(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_VERIFIED))
MessageTypes.isIdentityDefault(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.IDENTITY_DEFAULT))
record.remoteDeleted -> {
builder.remoteDeletedMessage = RemoteDeletedMessage()
}
MessageTypes.isJoinedType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.JOINED_SIGNAL)
}
MessageTypes.isIdentityUpdate(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_UPDATE)
}
MessageTypes.isIdentityVerified(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_VERIFIED)
}
MessageTypes.isIdentityDefault(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.IDENTITY_DEFAULT)
}
MessageTypes.isChangeNumber(record.type) -> {
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
builder.sms = false
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHANGE_NUMBER)
}
MessageTypes.isBoostRequest(record.type) -> {
builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_REQUEST))
builder.sms = false
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BOOST_REQUEST)
}
MessageTypes.isEndSessionType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.END_SESSION)
}
MessageTypes.isChatSessionRefresh(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.CHAT_SESSION_REFRESH)
}
MessageTypes.isBadDecryptType(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.BAD_DECRYPT)
}
MessageTypes.isPaymentsActivated(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENTS_ACTIVATED)
}
MessageTypes.isPaymentsRequestToActivate(record.type) -> {
builder.updateMessage = simpleUpdate(SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST)
}
MessageTypes.isEndSessionType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.END_SESSION))
MessageTypes.isChatSessionRefresh(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHAT_SESSION_REFRESH))
MessageTypes.isBadDecryptType(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BAD_DECRYPT))
MessageTypes.isPaymentsActivated(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.PAYMENTS_ACTIVATED))
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 = 0
}
MessageTypes.isProfileChange(record.type) -> {
val profileChangeDetails = if (record.messageExtras != null) {
record.messageExtras.profileChangeDetails
} else {
Base64.decodeOrNull(record.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) }
}
builder.updateMessage = if (profileChangeDetails?.profileNameChange != null) {
ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue))
} else if (profileChangeDetails?.learnedProfileName != null) {
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username))
} else {
continue
}
builder.sms = false
builder.updateMessage = record.toProfileChangeUpdate()
}
MessageTypes.isSessionSwitchoverType(record.type) -> {
builder.updateMessage = ChatUpdateMessage(
sessionSwitchover = try {
val event = SessionSwitchoverEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
} catch (e: Exception) {
SessionSwitchoverChatUpdate()
}
)
builder.updateMessage = record.toSessionSwitchoverUpdate()
}
MessageTypes.isThreadMergeType(record.type) -> {
builder.updateMessage = ChatUpdateMessage(
threadMerge = try {
val event = ThreadMergeEvent.ADAPTER.decode(decodeOrThrow(record.body!!))
ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!)
} catch (e: Exception) {
ThreadMergeChatUpdate()
}
)
builder.updateMessage = record.toThreadMergeUpdate()
}
MessageTypes.isGroupV2(record.type) && MessageTypes.isGroupUpdate(record.type) -> {
val groupChange = record.messageExtras?.gv2UpdateDescription?.groupChangeUpdate
if (groupChange != null) {
builder.updateMessage = ChatUpdateMessage(
groupChange = groupChange
)
} else if (record.body != null) {
try {
val decoded: ByteArray = decode(record.body)
val context = DecryptedGroupV2Context.ADAPTER.decode(decoded)
builder.updateMessage = ChatUpdateMessage(
groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account().getServiceIds(), context)
)
} catch (e: IOException) {
continue
}
} else {
continue
}
builder.updateMessage = record.toGroupUpdate()
}
MessageTypes.isCallLog(record.type) -> {
builder.sms = false
val call = calls.getCallByMessageId(record.id)
if (call != null) {
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(),
startedCallRecipientId = call.ringerRecipient?.toLong(),
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(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.MISSED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isMissedVideoCall(record.type) -> {
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(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isIncomingVideoCall(record.type) -> {
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(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.OUTGOING
)
)
}
MessageTypes.isOutgoingVideoCall(record.type) -> {
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)
builder.updateMessage = ChatUpdateMessage(
groupCall = GroupCall(
state = GroupCall.State.GENERIC,
startedCallRecipientId = recipients.getByAci(ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid))).getOrNull()?.toLong(),
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
)
)
} catch (exception: java.lang.Exception) {
continue
}
}
}
}
builder.updateMessage = record.toCallUpdate()
}
MessageTypes.isPaymentsNotification(record.type) -> {
val paymentUuid = UuidUtil.parseOrNull(record.body)
val payment = if (paymentUuid != null) {
SignalDatabase.payments.getPayment(paymentUuid)
} else {
null
builder.paymentNotification = record.toPaymentNotificationUpdate()
}
MessageTypes.isGiftBadge(record.type) -> {
builder.giftBadge = record.toGiftBadgeUpdate()
}
!record.sharedContacts.isNullOrEmpty() -> {
builder.contactMessage = record.toContactMessage(reactionsById[id], attachmentsById[id])
}
else -> {
if (record.body == null && !attachmentsById.containsKey(record.id)) {
Log.w(TAG, "Record with ID ${record.id} missing a body and doesn't have attachments. Skipping.")
continue
}
if (payment == null) {
builder.paymentNotification = PaymentNotification()
val attachments = attachmentsById[record.id]
val sticker = attachments?.firstOrNull { dbAttachment ->
dbAttachment.isSticker
}
if (sticker?.stickerLocator != null) {
builder.stickerMessage = sticker.toStickerMessage(reactionsById[id])
} else {
builder.paymentNotification = PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
note = payment.note,
transactionDetails = payment.getTransactionDetails()
)
builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
}
}
record.body == null && !attachmentsById.containsKey(record.id) -> {
Log.w(TAG, "Record missing a body and doesnt have attachments, skipping")
continue
}
else -> builder.standardMessage = record.toStandardMessage(reactionsById[id], mentions = mentionsById[id], attachments = attachmentsById[record.id])
}
if (record.latestRevisionId == null) {
val previousEdits = revisionMap.remove(record.id)
if (previousEdits != null) {
@@ -369,7 +242,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
return if (buffer.isNotEmpty()) {
buffer.remove()
} else {
throw NoSuchElementException()
null
}
}
@@ -377,14 +250,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
cursor.close()
}
private fun String.e164ToLong(): Long? {
val fixed = if (this.startsWith("+")) {
this.substring(1)
} else {
this
}
return fixed.toLongOrNull()
private fun simpleUpdate(type: SimpleChatUpdate.Type): ChatUpdateMessage {
return ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = type))
}
private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): ChatItem.Builder {
@@ -397,7 +264,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
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)
sms = record.type.isSmsType()
if (MessageTypes.isCallLog(record.type)) {
directionless = ChatItem.DirectionlessMessageDetails()
} else if (MessageTypes.isOutgoingMessageType(record.type)) {
@@ -415,6 +282,385 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun BackupMessageRecord.toProfileChangeUpdate(): ChatUpdateMessage? {
val profileChangeDetails = if (this.messageExtras != null) {
this.messageExtras.profileChangeDetails
} else {
Base64.decodeOrNull(this.body)?.let { ProfileChangeDetails.ADAPTER.decode(it) }
}
return if (profileChangeDetails?.profileNameChange != null) {
ChatUpdateMessage(profileChange = ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue))
} else if (profileChangeDetails?.learnedProfileName != null) {
ChatUpdateMessage(learnedProfileChange = LearnedProfileChatUpdate(e164 = profileChangeDetails.learnedProfileName.e164?.e164ToLong(), username = profileChangeDetails.learnedProfileName.username))
} else {
null
}
}
private fun BackupMessageRecord.toSessionSwitchoverUpdate(): ChatUpdateMessage {
if (this.body == null) {
return ChatUpdateMessage(sessionSwitchover = SessionSwitchoverChatUpdate())
}
return ChatUpdateMessage(
sessionSwitchover = try {
val event = SessionSwitchoverEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
SessionSwitchoverChatUpdate(event.e164.e164ToLong()!!)
} catch (e: IOException) {
SessionSwitchoverChatUpdate()
}
)
}
private fun BackupMessageRecord.toThreadMergeUpdate(): ChatUpdateMessage {
if (this.body == null) {
return ChatUpdateMessage(threadMerge = ThreadMergeChatUpdate())
}
return ChatUpdateMessage(
threadMerge = try {
val event = ThreadMergeEvent.ADAPTER.decode(Base64.decodeOrThrow(this.body))
ThreadMergeChatUpdate(event.previousE164.e164ToLong()!!)
} catch (e: IOException) {
ThreadMergeChatUpdate()
}
)
}
private fun BackupMessageRecord.toGroupUpdate(): ChatUpdateMessage? {
val groupChange = this.messageExtras?.gv2UpdateDescription?.groupChangeUpdate
return if (groupChange != null) {
ChatUpdateMessage(
groupChange = groupChange
)
} else if (this.body != null) {
try {
val decoded: ByteArray = Base64.decode(this.body)
val context = DecryptedGroupV2Context.ADAPTER.decode(decoded)
ChatUpdateMessage(
groupChange = GroupsV2UpdateMessageConverter.translateDecryptedChange(selfIds = SignalStore.account.getServiceIds(), context)
)
} catch (e: IOException) {
null
}
} else {
null
}
}
private fun BackupMessageRecord.toCallUpdate(): ChatUpdateMessage? {
val call = calls.getCallByMessageId(this.id)
return if (call != null) {
call.toCallUpdate()
} else {
when {
MessageTypes.isMissedAudioCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.MISSED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isMissedVideoCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.MISSED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isIncomingAudioCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isIncomingVideoCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.INCOMING
)
)
}
MessageTypes.isOutgoingAudioCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.AUDIO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.OUTGOING
)
)
}
MessageTypes.isOutgoingVideoCall(this.type) -> {
ChatUpdateMessage(
individualCall = IndividualCall(
type = IndividualCall.Type.VIDEO_CALL,
state = IndividualCall.State.ACCEPTED,
direction = IndividualCall.Direction.OUTGOING
)
)
}
MessageTypes.isGroupCall(this.type) -> {
try {
val groupCallUpdateDetails = GroupCallUpdateDetailsUtil.parse(this.body)
ChatUpdateMessage(
groupCall = GroupCall(
state = GroupCall.State.GENERIC,
startedCallRecipientId = recipients.getByAci(ACI.from(UuidUtil.parseOrThrow(groupCallUpdateDetails.startedCallUuid))).getOrNull()?.toLong(),
startedCallTimestamp = groupCallUpdateDetails.startedCallTimestamp,
endedCallTimestamp = groupCallUpdateDetails.endedCallTimestamp
)
)
} catch (exception: IOException) {
null
}
}
else -> {
null
}
}
}
}
private fun CallTable.Call.toCallUpdate(): ChatUpdateMessage? {
return if (this.type == CallTable.Type.GROUP_CALL) {
ChatUpdateMessage(
groupCall = GroupCall(
callId = this.messageId,
state = when (this.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.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
CallTable.Event.DELETE -> return null
},
ringerRecipientId = this.ringerRecipient?.toLong(),
startedCallRecipientId = this.ringerRecipient?.toLong(),
startedCallTimestamp = this.timestamp
)
)
} else if (this.type != CallTable.Type.AD_HOC_CALL) {
ChatUpdateMessage(
individualCall = IndividualCall(
callId = this.callId,
type = if (this.type == CallTable.Type.VIDEO_CALL) IndividualCall.Type.VIDEO_CALL else IndividualCall.Type.AUDIO_CALL,
direction = if (this.direction == CallTable.Direction.INCOMING) IndividualCall.Direction.INCOMING else IndividualCall.Direction.OUTGOING,
state = when (this.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 = this.timestamp
)
)
} else {
null
}
}
private fun BackupMessageRecord.toPaymentNotificationUpdate(): PaymentNotification {
val paymentUuid = UuidUtil.parseOrNull(this.body)
val payment = if (paymentUuid != null) {
SignalDatabase.payments.getPayment(paymentUuid)
} else {
null
}
return if (payment == null) {
PaymentNotification()
} else {
PaymentNotification(
amountMob = payment.amount.serializeAmountString(),
feeMob = payment.fee.serializeAmountString(),
note = payment.note,
transactionDetails = payment.getTransactionDetails()
)
}
}
private fun BackupMessageRecord.parseSharedContacts(attachments: List<DatabaseAttachment>?): List<Contact> {
if (this.sharedContacts.isNullOrEmpty()) {
return emptyList()
}
val attachmentIdMap: Map<AttachmentId, DatabaseAttachment> = attachments?.associateBy { it.attachmentId } ?: emptyMap()
try {
val contacts: MutableList<Contact> = LinkedList()
val jsonContacts = JSONArray(sharedContacts)
for (i in 0 until jsonContacts.length()) {
val contact: Contact = Contact.deserialize(jsonContacts.getJSONObject(i).toString())
if (contact.avatar != null && contact.avatar!!.attachmentId != null) {
val attachment = attachmentIdMap[contact.avatar!!.attachmentId]
val updatedAvatar = Contact.Avatar(
contact.avatar!!.attachmentId,
attachment,
contact.avatar!!.isProfile
)
contacts += Contact(contact, updatedAvatar)
} else {
contacts += contact
}
}
return contacts
} catch (e: JSONException) {
Log.w(TAG, "Failed to parse shared contacts.", e)
} catch (e: IOException) {
Log.w(TAG, "Failed to parse shared contacts.", e)
}
return emptyList()
}
private fun BackupMessageRecord.parseLinkPreviews(attachments: List<DatabaseAttachment>?): List<LinkPreview> {
if (linkPreview.isNullOrEmpty()) {
return emptyList()
}
val attachmentIdMap: Map<AttachmentId, DatabaseAttachment> = attachments?.associateBy { it.attachmentId } ?: emptyMap()
try {
val previews: MutableList<LinkPreview> = LinkedList()
val jsonPreviews = JSONArray(linkPreview)
for (i in 0 until jsonPreviews.length()) {
val preview = LinkPreview.deserialize(jsonPreviews.getJSONObject(i).toString())
if (preview.attachmentId != null) {
val attachment = attachmentIdMap[preview.attachmentId]
if (attachment != null) {
previews += LinkPreview(preview.url, preview.title, preview.description, preview.date, attachment)
} else {
previews += preview
}
} else {
previews += preview
}
}
return previews
} catch (e: JSONException) {
Log.w(TAG, "Failed to parse link preview", e)
} catch (e: IOException) {
Log.w(TAG, "Failed to parse shared contacts.", e)
}
return emptyList()
}
private fun LinkPreview.toBackupLinkPreview(): org.thoughtcrime.securesms.backup.v2.proto.LinkPreview {
return org.thoughtcrime.securesms.backup.v2.proto.LinkPreview(
url = url,
title = title,
image = (thumbnail.orNull() as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
description = description,
date = date
)
}
private fun BackupMessageRecord.toContactMessage(reactionRecords: List<ReactionRecord>?, attachments: List<DatabaseAttachment>?): ContactMessage {
val sharedContacts = parseSharedContacts(attachments)
val contacts = sharedContacts.map {
ContactAttachment(
name = it.name.toBackup(),
avatar = (it.avatar?.attachment as? DatabaseAttachment)?.toBackupAttachment()?.pointer,
organization = it.organization,
number = it.phoneNumbers.map { phone ->
ContactAttachment.Phone(
value_ = phone.number,
type = phone.type.toBackup(),
label = phone.label
)
},
email = it.emails.map { email ->
ContactAttachment.Email(
value_ = email.email,
label = email.label,
type = email.type.toBackup()
)
},
address = it.postalAddresses.map { address ->
ContactAttachment.PostalAddress(
type = address.type.toBackup(),
label = address.label,
street = address.street,
pobox = address.poBox,
neighborhood = address.neighborhood,
city = address.city,
region = address.region,
postcode = address.postalCode,
country = address.country
)
}
)
}
return ContactMessage(
contact = contacts,
reactions = reactionRecords.toBackupReactions()
)
}
private fun Contact.Name.toBackup(): ContactAttachment.Name {
return ContactAttachment.Name(
givenName = givenName,
familyName = familyName,
prefix = prefix,
suffix = suffix,
middleName = middleName,
displayName = displayName
)
}
private fun Contact.Phone.Type.toBackup(): ContactAttachment.Phone.Type {
return when (this) {
Contact.Phone.Type.HOME -> ContactAttachment.Phone.Type.HOME
Contact.Phone.Type.MOBILE -> ContactAttachment.Phone.Type.MOBILE
Contact.Phone.Type.WORK -> ContactAttachment.Phone.Type.WORK
Contact.Phone.Type.CUSTOM -> ContactAttachment.Phone.Type.CUSTOM
}
}
private fun Contact.Email.Type.toBackup(): ContactAttachment.Email.Type {
return when (this) {
Contact.Email.Type.HOME -> ContactAttachment.Email.Type.HOME
Contact.Email.Type.MOBILE -> ContactAttachment.Email.Type.MOBILE
Contact.Email.Type.WORK -> ContactAttachment.Email.Type.WORK
Contact.Email.Type.CUSTOM -> ContactAttachment.Email.Type.CUSTOM
}
}
private fun Contact.PostalAddress.Type.toBackup(): ContactAttachment.PostalAddress.Type {
return when (this) {
Contact.PostalAddress.Type.HOME -> ContactAttachment.PostalAddress.Type.HOME
Contact.PostalAddress.Type.WORK -> ContactAttachment.PostalAddress.Type.WORK
Contact.PostalAddress.Type.CUSTOM -> ContactAttachment.PostalAddress.Type.CUSTOM
}
}
private fun BackupMessageRecord.toStandardMessage(reactionRecords: List<ReactionRecord>?, mentions: List<Mention>?, attachments: List<DatabaseAttachment>?): StandardMessage {
val text = if (body == null) {
null
@@ -424,14 +670,18 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
bodyRanges = (this.bodyRanges?.toBackupBodyRanges() ?: emptyList()) + (mentions?.toBackupBodyRanges() ?: emptyList())
)
}
val linkPreviews = parseLinkPreviews(attachments)
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orElse(null) }.toSet()
val quotedAttachments = attachments?.filter { it.quote } ?: emptyList()
val messageAttachments = attachments?.filter { !it.quote } ?: emptyList()
val messageAttachments = attachments
?.filterNot { it.quote }
?.filterNot { linkPreviewAttachments.contains(it) }
?: emptyList()
return StandardMessage(
quote = this.toQuote(quotedAttachments),
text = text,
attachments = messageAttachments.toBackupAttachments(),
// TODO Link previews!
linkPreview = emptyList(),
linkPreview = linkPreviews.map { it.toBackupLinkPreview() },
longText = null,
reactions = reactionRecords.toBackupReactions()
)
@@ -456,6 +706,39 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun BackupMessageRecord.toGiftBadgeUpdate(): BackupGiftBadge {
val giftBadge = try {
GiftBadge.ADAPTER.decode(Base64.decode(this.body ?: ""))
} catch (e: IOException) {
Log.w(TAG, "Failed to decode GiftBadge!")
return BackupGiftBadge()
}
return BackupGiftBadge(
receiptCredentialPresentation = giftBadge.redemptionToken,
state = when (giftBadge.redemptionState) {
GiftBadge.RedemptionState.REDEEMED -> BackupGiftBadge.State.REDEEMED
GiftBadge.RedemptionState.FAILED -> BackupGiftBadge.State.FAILED
GiftBadge.RedemptionState.PENDING -> BackupGiftBadge.State.UNOPENED
GiftBadge.RedemptionState.STARTED -> BackupGiftBadge.State.OPENED
}
)
}
private fun DatabaseAttachment.toStickerMessage(reactions: List<ReactionRecord>?): StickerMessage {
val stickerLocator = this.stickerLocator!!
return StickerMessage(
sticker = Sticker(
packId = Hex.fromStringCondensed(stickerLocator.packId).toByteString(),
packKey = Hex.fromStringCondensed(stickerLocator.packKey).toByteString(),
stickerId = stickerLocator.stickerId,
emoji = stickerLocator.emoji,
data_ = this.toBackupAttachment().pointer
),
reactions = reactions.toBackupReactions()
)
}
private fun List<DatabaseAttachment>.toBackupQuoteAttachments(): List<Quote.QuotedAttachment> {
return this.map { attachment ->
Quote.QuotedAttachment(
@@ -484,7 +767,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
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(),
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = remoteDigest.toByteString()
)
@@ -496,7 +779,7 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
cdnKey = this.remoteLocation,
cdnNumber = this.cdn.cdnNumber,
uploadTimestamp = this.uploadTimestamp,
key = decode(remoteKey).toByteString(),
key = Base64.decode(remoteKey).toByteString(),
size = this.size.toInt(),
digest = remoteDigest.toByteString()
)
@@ -514,7 +797,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
MessageAttachment.Flag.BORDERLESS
} else {
MessageAttachment.Flag.NONE
}
},
clientUuid = uuid?.let { UuidUtil.toByteString(uuid) }
)
}
@@ -703,6 +987,28 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
}
}
private fun Long.isSmsType(): Boolean {
if (MessageTypes.isSecureType(this)) {
return false
}
if (MessageTypes.isCallLog(this)) {
return false
}
return MessageTypes.isOutgoingMessageType(this) || MessageTypes.isInboxType(this)
}
private fun String.e164ToLong(): Long? {
val fixed = if (this.startsWith("+")) {
this.substring(1)
} else {
this
}
return fixed.toLongOrNull()
}
private fun Cursor.toBackupMessageRecord(): BackupMessageRecord {
return BackupMessageRecord(
id = this.requireLong(MessageTable.ID),
@@ -719,6 +1025,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED),
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
linkPreview = this.requireString(MessageTable.LINK_PREVIEWS),
sharedContacts = this.requireString(MessageTable.SHARED_CONTACTS),
quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID),
quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR),
quoteBody = this.requireString(MessageTable.QUOTE_BODY),
@@ -754,6 +1062,8 @@ class ChatItemExportIterator(private val cursor: Cursor, private val batchSize:
val expireStarted: Long,
val remoteDeleted: Boolean,
val sealedSender: Boolean,
val linkPreview: String?,
val sharedContacts: String?,
val quoteTargetSentTimestamp: Long,
val quoteAuthor: Long,
val quoteBody: String?,

View File

@@ -7,7 +7,9 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import okio.ByteString
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
@@ -22,8 +24,11 @@ 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.ContactAttachment
import org.thoughtcrime.securesms.backup.v2.proto.FilePointer
import org.thoughtcrime.securesms.backup.v2.proto.GroupCall
import org.thoughtcrime.securesms.backup.v2.proto.IndividualCall
import org.thoughtcrime.securesms.backup.v2.proto.LinkPreview
import org.thoughtcrime.securesms.backup.v2.proto.MessageAttachment
import org.thoughtcrime.securesms.backup.v2.proto.PaymentNotification
import org.thoughtcrime.securesms.backup.v2.proto.Quote
@@ -31,6 +36,8 @@ 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.backup.v2.proto.Sticker
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.database.GroupReceiptTable
@@ -49,6 +56,7 @@ import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue
import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescription
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PaymentTombstone
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
@@ -61,6 +69,7 @@ import org.thoughtcrime.securesms.payments.State
import org.thoughtcrime.securesms.payments.proto.PaymentMetaData
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.backup.MediaName
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
@@ -72,6 +81,7 @@ import org.whispersystems.signalservice.internal.push.DataMessage
import java.math.BigInteger
import java.util.Optional
import java.util.UUID
import org.thoughtcrime.securesms.backup.v2.proto.GiftBadge as BackupGiftBadge
/**
* An object that will ingest all fo the [ChatItem]s you want to write, buffer them until hitting a specified batch size, and then batch insert them
@@ -170,6 +180,8 @@ class ChatItemImportInserter(
}
val messageInsert = chatItem.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
if (chatItem.revisions.isNotEmpty()) {
// Flush to avoid having revisions cross batch boundaries, which will cause a foreign key failure
flush()
val originalId = messageId
val latestRevisionId = originalId + chatItem.revisions.size
val sortedRevisions = chatItem.revisions.sortedBy { it.dateSent }.map { it.toMessageInsert(fromLocalRecipientId, chatLocalRecipientId, localThreadId) }
@@ -308,6 +320,60 @@ class ChatItemImportInserter(
}
}
}
if (this.contactMessage != null) {
val contacts = this.contactMessage.contact.map { backupContact ->
Contact(
backupContact.name.toLocal(),
backupContact.organization,
backupContact.number.map { phone ->
Contact.Phone(
phone.value_ ?: "",
phone.type.toLocal(),
phone.label
)
},
backupContact.email.map { email ->
Contact.Email(
email.value_ ?: "",
email.type.toLocal(),
email.label
)
},
backupContact.address.map { address ->
Contact.PostalAddress(
address.type.toLocal(),
address.label,
address.street,
address.pobox,
address.neighborhood,
address.city,
address.region,
address.postcode,
address.country
)
},
Contact.Avatar(null, backupContact.avatar.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true), true)
)
}
val contactAttachments = contacts.mapNotNull { it.avatarAttachment }
if (contacts.isNotEmpty()) {
followUp = { messageRowId ->
val attachmentMap = if (contactAttachments.isNotEmpty()) {
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, contactAttachments, emptyList())
} else {
emptyMap()
}
db.update(
MessageTable.TABLE_NAME,
contentValuesOf(
MessageTable.SHARED_CONTACTS to SignalDatabase.messages.getSerializedSharedContacts(attachmentMap, contacts)
),
"${MessageTable.ID} = ?",
SqlUtil.buildArgs(messageRowId)
)
}
}
}
if (this.standardMessage != null) {
val bodyRanges = this.standardMessage.text?.bodyRanges
if (!bodyRanges.isNullOrEmpty()) {
@@ -328,15 +394,36 @@ class ChatItemImportInserter(
}
}
}
val linkPreviews = this.standardMessage.linkPreview.map { it.toLocalLinkPreview() }
val linkPreviewAttachments = linkPreviews.mapNotNull { it.thumbnail.orNull() }
val attachments = this.standardMessage.attachments.mapNotNull { attachment ->
attachment.toLocalAttachment()
}
val quoteAttachments = this.standardMessage.quote?.attachments?.mapNotNull {
it.toLocalAttachment()
} ?: emptyList()
if (attachments.isNotEmpty()) {
if (attachments.isNotEmpty() || linkPreviewAttachments.isNotEmpty() || quoteAttachments.isNotEmpty()) {
followUp = { messageRowId ->
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments, quoteAttachments)
val attachmentMap = SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, attachments + linkPreviewAttachments, quoteAttachments)
if (linkPreviews.isNotEmpty()) {
db.update(
MessageTable.TABLE_NAME,
contentValuesOf(
MessageTable.LINK_PREVIEWS to SignalDatabase.messages.getSerializedLinkPreviews(attachmentMap, linkPreviews)
),
"${MessageTable.ID} = ?",
SqlUtil.buildArgs(messageRowId)
)
}
}
}
}
if (this.stickerMessage != null) {
val sticker = this.stickerMessage.sticker
val attachment = sticker.toLocalAttachment()
if (attachment != null) {
followUp = { messageRowId ->
SignalDatabase.attachments.insertAttachmentsForMessage(messageRowId, listOf(attachment), emptyList())
}
}
}
@@ -396,6 +483,7 @@ class ChatItemImportInserter(
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage)
this.paymentNotification != null -> contentValues.addPaymentNotification(this, chatRecipientId)
this.giftBadge != null -> contentValues.addGiftBadge(this.giftBadge)
}
return contentValues
@@ -503,6 +591,10 @@ class ChatItemImportInserter(
type = type or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
}
if (this.giftBadge != null) {
type = type or MessageTypes.SPECIAL_TYPE_GIFT_BADGE
}
return type
}
@@ -538,6 +630,7 @@ class ChatItemImportInserter(
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE or typeWithoutBase
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED or typeWithoutBase
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST or typeWithoutBase
SimpleChatUpdate.Type.UNSUPPORTED_PROTOCOL_MESSAGE -> MessageTypes.UNSUPPORTED_MESSAGE_TYPE or typeWithoutBase
}
}
updateMessage.expirationTimerChange != null -> {
@@ -548,13 +641,13 @@ class ChatItemImportInserter(
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
val profileChangeDetails = ProfileChangeDetails(profileNameChange = ProfileChangeDetails.StringChange(previous = updateMessage.profileChange.previousName, newValue = updateMessage.profileChange.newName))
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
put(MessageTable.MESSAGE_EXTRAS, Base64.encodeWithPadding(messageExtras))
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
}
updateMessage.learnedProfileChange != null -> {
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
val profileChangeDetails = ProfileChangeDetails(learnedProfileName = ProfileChangeDetails.LearnedProfileName(e164 = updateMessage.learnedProfileChange.e164?.toString(), username = updateMessage.learnedProfileChange.username))
val messageExtras = MessageExtras(profileChangeDetails = profileChangeDetails).encode()
put(MessageTable.MESSAGE_EXTRAS, Base64.encodeWithPadding(messageExtras))
put(MessageTable.MESSAGE_EXTRAS, messageExtras)
}
updateMessage.sessionSwitchover != null -> {
typeFlags = MessageTypes.SESSION_SWITCHOVER_TYPE or (getAsLong(MessageTable.TYPE) and MessageTypes.BASE_TYPE_MASK.inv())
@@ -680,6 +773,20 @@ class ChatItemImportInserter(
)
}
private fun ContentValues.addGiftBadge(giftBadge: BackupGiftBadge) {
val dbGiftBadge = GiftBadge(
redemptionToken = giftBadge.receiptCredentialPresentation,
redemptionState = when (giftBadge.state) {
BackupGiftBadge.State.UNOPENED -> GiftBadge.RedemptionState.PENDING
BackupGiftBadge.State.OPENED -> GiftBadge.RedemptionState.STARTED
BackupGiftBadge.State.REDEEMED -> GiftBadge.RedemptionState.REDEEMED
BackupGiftBadge.State.FAILED -> GiftBadge.RedemptionState.FAILED
}
)
put(MessageTable.BODY, Base64.encodeWithPadding(GiftBadge.ADAPTER.encode(dbGiftBadge)))
}
private fun String?.tryParseMoney(): Money? {
if (this.isNullOrEmpty()) {
return null
@@ -803,74 +910,165 @@ class ChatItemImportInserter(
}
}
private fun MessageAttachment.toLocalAttachment(contentType: String? = pointer?.contentType, fileName: String? = pointer?.fileName): Attachment? {
if (pointer == null) return null
if (pointer.attachmentLocator != null) {
private fun FilePointer?.toLocalAttachment(voiceNote: Boolean, borderless: Boolean, gif: Boolean, wasDownloaded: Boolean, stickerLocator: StickerLocator? = null, contentType: String? = this?.contentType, fileName: String? = this?.fileName, uuid: ByteString? = null): Attachment? {
if (this == null) return null
if (attachmentLocator != null) {
val signalAttachmentPointer = SignalServiceAttachmentPointer(
pointer.attachmentLocator.cdnNumber,
SignalServiceAttachmentRemoteId.from(pointer.attachmentLocator.cdnKey),
attachmentLocator.cdnNumber,
SignalServiceAttachmentRemoteId.from(attachmentLocator.cdnKey),
contentType,
pointer.attachmentLocator.key.toByteArray(),
Optional.ofNullable(pointer.attachmentLocator.size),
attachmentLocator.key.toByteArray(),
Optional.ofNullable(attachmentLocator.size),
Optional.empty(),
pointer.width ?: 0,
pointer.height ?: 0,
Optional.ofNullable(pointer.attachmentLocator.digest.toByteArray()),
Optional.ofNullable(pointer.incrementalMac?.toByteArray()),
pointer.incrementalMacChunkSize ?: 0,
width ?: 0,
height ?: 0,
Optional.ofNullable(attachmentLocator.digest.toByteArray()),
Optional.ofNullable(incrementalMac?.toByteArray()),
incrementalMacChunkSize ?: 0,
Optional.ofNullable(fileName),
flag == MessageAttachment.Flag.VOICE_MESSAGE,
flag == MessageAttachment.Flag.BORDERLESS,
flag == MessageAttachment.Flag.GIF,
Optional.ofNullable(pointer.caption),
Optional.ofNullable(pointer.blurHash),
pointer.attachmentLocator.uploadTimestamp
voiceNote,
borderless,
gif,
Optional.ofNullable(caption),
Optional.ofNullable(blurHash),
attachmentLocator.uploadTimestamp,
UuidUtil.fromByteStringOrNull(uuid)
)
return PointerAttachment.forPointer(
pointer = Optional.of(signalAttachmentPointer),
stickerLocator = stickerLocator,
transferState = if (wasDownloaded) AttachmentTable.TRANSFER_NEEDS_RESTORE else AttachmentTable.TRANSFER_PROGRESS_PENDING
).orNull()
} else if (pointer.invalidAttachmentLocator != null) {
} else if (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
incrementalMac = incrementalMac?.toByteArray(),
incrementalMacChunkSize = incrementalMacChunkSize,
width = width,
height = height,
caption = caption,
blurHash = blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
} else if (pointer.backupLocator != null) {
} else if (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(),
archiveThumbnailMediaId = backupState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(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
size = backupLocator.size.toLong(),
cdn = backupLocator.transitCdnNumber ?: Cdn.CDN_0.cdnNumber,
key = backupLocator.key.toByteArray(),
cdnKey = backupLocator.transitCdnKey,
archiveCdn = backupLocator.cdnNumber,
archiveMediaName = backupLocator.mediaName,
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(backupLocator.mediaName)).encode(),
archiveThumbnailMediaId = backupState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(backupLocator.mediaName)).encode(),
digest = backupLocator.digest.toByteArray(),
incrementalMac = incrementalMac?.toByteArray(),
incrementalMacChunkSize = incrementalMacChunkSize,
width = width,
height = height,
caption = caption,
blurHash = blurHash,
voiceNote = voiceNote,
borderless = borderless,
gif = gif,
quote = false,
stickerLocator = stickerLocator,
uuid = UuidUtil.fromByteStringOrNull(uuid)
)
}
return null
}
private fun Sticker?.toLocalAttachment(): Attachment? {
if (this == null) return null
return data_.toLocalAttachment(
voiceNote = false,
gif = false,
borderless = false,
wasDownloaded = true,
stickerLocator = StickerLocator(
packId = Hex.toStringCondensed(packId.toByteArray()),
packKey = Hex.toStringCondensed(packKey.toByteArray()),
stickerId = stickerId,
emoji = emoji
)
)
}
private fun LinkPreview.toLocalLinkPreview(): org.thoughtcrime.securesms.linkpreview.LinkPreview {
return org.thoughtcrime.securesms.linkpreview.LinkPreview(
this.url,
this.title ?: "",
this.description ?: "",
this.date ?: 0,
Optional.ofNullable(this.image?.toLocalAttachment(voiceNote = false, borderless = false, gif = false, wasDownloaded = true))
)
}
private fun MessageAttachment.toLocalAttachment(): Attachment? {
return pointer?.toLocalAttachment(
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
gif = flag == MessageAttachment.Flag.GIF,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
wasDownloaded = wasDownloaded,
uuid = clientUuid
)
}
private fun ContactAttachment.Name?.toLocal(): Contact.Name {
return Contact.Name(this?.displayName, this?.givenName, this?.familyName, this?.prefix, this?.suffix, this?.middleName)
}
private fun ContactAttachment.Phone.Type?.toLocal(): Contact.Phone.Type {
return when (this) {
ContactAttachment.Phone.Type.HOME -> Contact.Phone.Type.HOME
ContactAttachment.Phone.Type.MOBILE -> Contact.Phone.Type.MOBILE
ContactAttachment.Phone.Type.WORK -> Contact.Phone.Type.WORK
ContactAttachment.Phone.Type.CUSTOM,
ContactAttachment.Phone.Type.UNKNOWN,
null -> Contact.Phone.Type.CUSTOM
}
}
private fun ContactAttachment.Email.Type?.toLocal(): Contact.Email.Type {
return when (this) {
ContactAttachment.Email.Type.HOME -> Contact.Email.Type.HOME
ContactAttachment.Email.Type.MOBILE -> Contact.Email.Type.MOBILE
ContactAttachment.Email.Type.WORK -> Contact.Email.Type.WORK
ContactAttachment.Email.Type.CUSTOM,
ContactAttachment.Email.Type.UNKNOWN,
null -> Contact.Email.Type.CUSTOM
}
}
private fun ContactAttachment.PostalAddress.Type?.toLocal(): Contact.PostalAddress.Type {
return when (this) {
ContactAttachment.PostalAddress.Type.HOME -> Contact.PostalAddress.Type.HOME
ContactAttachment.PostalAddress.Type.WORK -> Contact.PostalAddress.Type.WORK
ContactAttachment.PostalAddress.Type.CUSTOM,
ContactAttachment.PostalAddress.Type.UNKNOWN,
null -> Contact.PostalAddress.Type.CUSTOM
}
}
private fun MessageAttachment.toLocalAttachment(contentType: String?, fileName: String?): Attachment? {
return pointer?.toLocalAttachment(
voiceNote = flag == MessageAttachment.Flag.VOICE_MESSAGE,
gif = flag == MessageAttachment.Flag.GIF,
borderless = flag == MessageAttachment.Flag.BORDERLESS,
wasDownloaded = wasDownloaded,
contentType = contentType,
fileName = fileName,
uuid = clientUuid
)
}
private fun Quote.QuotedAttachment.toLocalAttachment(): Attachment? {
return thumbnail?.toLocalAttachment(this.contentType, this.fileName)
?: if (this.contentType == null) null else PointerAttachment.forPointer(quotedAttachment = DataMessage.Quote.QuotedAttachment(contentType = this.contentType, fileName = this.fileName, thumbnail = null)).orNull()

View File

@@ -15,6 +15,8 @@ import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireObject
import org.signal.core.util.select
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList
import org.thoughtcrime.securesms.backup.v2.proto.DistributionListItem
import org.thoughtcrime.securesms.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
@@ -60,19 +62,28 @@ fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
.map { recipient ->
BackupRecipient(
id = recipient.id.toLong(),
distributionList = BackupDistributionList(
name = recipient.record.name,
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
allowReplies = recipient.record.allowsReplies,
deletionTimestamp = recipient.record.deletedAtTimestamp,
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = recipient.record.members.map { it.toLong() }
)
distributionList = if (recipient.record.deletedAtTimestamp != 0L) {
DistributionListItem(
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
deletionTimestamp = recipient.record.deletedAtTimestamp
)
} else {
DistributionListItem(
distributionId = recipient.record.distributionId.asUuid().toByteArray().toByteString(),
distributionList = DistributionList(
name = recipient.record.name,
allowReplies = recipient.record.allowsReplies,
privacyMode = recipient.record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = recipient.record.members.map { it.toLong() }
)
)
}
)
}
}
fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, backupState: BackupState): RecipientId {
fun DistributionListTables.restoreFromBackup(dlistItem: DistributionListItem, backupState: BackupState): RecipientId? {
val dlist = dlistItem.distributionList ?: return null
val members: List<RecipientId> = dlist.memberRecipientIds
.mapNotNull { backupState.backupToLocalRecipientId[it] }
@@ -80,15 +91,25 @@ fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, back
Log.w(TAG, "Couldn't find some member recipients! Missing backup recipientIds: ${dlist.memberRecipientIds.toSet() - members.toSet()}")
}
val dlistId = this.createList(
name = dlist.name,
members = members,
distributionId = DistributionId.from(UuidUtil.fromByteString(dlist.distributionId)),
allowsReplies = dlist.allowReplies,
deletionTimestamp = dlist.deletionTimestamp,
storageId = null,
privacyMode = dlist.privacyMode.toLocalPrivacyMode()
)!!
val distributionId = DistributionId.from(UuidUtil.fromByteString(dlistItem.distributionId))
val privacyMode = dlist.privacyMode.toLocalPrivacyMode()
val dlistId = if (distributionId == DistributionId.MY_STORY) {
setPrivacyMode(DistributionListId.MY_STORY, privacyMode)
members.forEach { addMemberToList(DistributionListId.MY_STORY, privacyMode, it) }
setAllowsReplies(DistributionListId.MY_STORY, dlist.allowReplies)
DistributionListId.MY_STORY
} else {
createList(
name = dlist.name,
members = members,
distributionId = distributionId,
allowsReplies = dlist.allowReplies,
deletionTimestamp = dlistItem.deletionTimestamp ?: 0,
storageId = null,
privacyMode = privacyMode
)!!
}
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
}

View File

@@ -33,6 +33,8 @@ fun MessageTable.getMessagesForBackup(backupTime: Long, archiveMedia: Boolean):
MessageTable.EXPIRE_STARTED,
MessageTable.REMOTE_DELETED,
MessageTable.UNIDENTIFIED,
MessageTable.LINK_PREVIEWS,
MessageTable.SHARED_CONTACTS,
MessageTable.QUOTE_ID,
MessageTable.QUOTE_AUTHOR,
MessageTable.QUOTE_BODY,

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.content.ContentValues
import android.database.Cursor
import androidx.core.content.contentValuesOf
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
@@ -85,6 +86,8 @@ fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
RecipientTable.PROFILE_FAMILY_NAME,
RecipientTable.PROFILE_JOINED_NAME,
RecipientTable.MUTE_UNTIL,
RecipientTable.CHAT_COLORS,
RecipientTable.CUSTOM_CHAT_COLORS_ID,
RecipientTable.EXTRAS
)
.from(RecipientTable.TABLE_NAME)
@@ -175,23 +178,30 @@ fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
)
val profileKey = contact.profileKey?.toByteArray()
val values = contentValuesOf(
RecipientTable.BLOCKED to contact.blocked,
RecipientTable.HIDDEN to (contact.visibility == Contact.Visibility.HIDDEN),
RecipientTable.TYPE to RecipientTable.RecipientType.INDIVIDUAL.id,
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
RecipientTable.USERNAME to contact.username,
RecipientTable.EXTRAS to contact.toLocalExtras().encode()
)
if (contact.registered != null) {
values.put(RecipientTable.UNREGISTERED_TIMESTAMP, 0L)
values.put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.REGISTERED.id)
} else if (contact.notRegistered != null) {
values.put(RecipientTable.UNREGISTERED_TIMESTAMP, contact.notRegistered.unregisteredTimestamp)
values.put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.NOT_REGISTERED.id)
}
writableDatabase
.update(RecipientTable.TABLE_NAME)
.values(
RecipientTable.BLOCKED to contact.blocked,
RecipientTable.HIDDEN to contact.hidden,
RecipientTable.TYPE to RecipientTable.RecipientType.INDIVIDUAL.id,
RecipientTable.PROFILE_FAMILY_NAME to contact.profileFamilyName.nullIfBlank(),
RecipientTable.PROFILE_GIVEN_NAME to contact.profileGivenName.nullIfBlank(),
RecipientTable.PROFILE_JOINED_NAME to ProfileName.fromParts(contact.profileGivenName.nullIfBlank(), contact.profileFamilyName.nullIfBlank()).toString().nullIfBlank(),
RecipientTable.PROFILE_KEY to if (profileKey == null) null else Base64.encodeWithPadding(profileKey),
RecipientTable.PROFILE_SHARING to contact.profileSharing.toInt(),
RecipientTable.REGISTERED to contact.registered.toLocalRegisteredState().id,
RecipientTable.USERNAME to contact.username,
RecipientTable.UNREGISTERED_TIMESTAMP to contact.unregisteredTimestamp,
RecipientTable.EXTRAS to contact.toLocalExtras().encode()
)
.values(values)
.where("${RecipientTable.ID} = ?", id)
.run()
@@ -200,7 +210,7 @@ fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
fun RecipientTable.restoreReleaseNotes(): RecipientId {
val releaseChannelId: RecipientId = insertReleaseChannelRecipient()
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelId)
SignalStore.releaseChannel.setReleaseChannelRecipientId(releaseChannelId)
setProfileName(releaseChannelId, ProfileName.asGiven("Signal"))
setMuted(releaseChannelId, Long.MAX_VALUE)
@@ -231,7 +241,7 @@ fun RecipientTable.restoreGroupFromBackup(group: Group): RecipientId {
}
val recipientId = writableDatabase.insert(RecipientTable.TABLE_NAME, null, values)
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState)
val restoredId = SignalDatabase.groups.create(masterKey, decryptedState, groupSendEndorsements = null)
if (restoredId != null) {
SignalDatabase.groups.setShowAsStoryState(restoredId, group.storySendMode.toGroupShowAsStoryState())
}
@@ -418,23 +428,28 @@ class BackupContactIterator(private val cursor: Cursor, private val selfId: Long
return null
}
val contactBuilder = Contact.Builder()
.aci(aci?.rawUuid?.toByteArray()?.toByteString())
.pni(pni?.rawUuid?.toByteArray()?.toByteString())
.username(cursor.requireString(RecipientTable.USERNAME))
.e164(cursor.requireString(RecipientTable.E164)?.e164ToLong())
.blocked(cursor.requireBoolean(RecipientTable.BLOCKED))
.visibility(if (cursor.requireBoolean(RecipientTable.HIDDEN)) Contact.Visibility.HIDDEN else Contact.Visibility.VISIBLE)
.profileKey(if (profileKey != null) Base64.decode(profileKey).toByteString() else null)
.profileSharing(cursor.requireBoolean(RecipientTable.PROFILE_SHARING))
.profileGivenName(cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank())
.profileFamilyName(cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank())
.hideStory(extras?.hideStory() ?: false)
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
contactBuilder.registered = Contact.Registered()
} else {
contactBuilder.notRegistered = Contact.NotRegistered(unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP))
}
return BackupRecipient(
id = id,
contact = Contact(
aci = aci?.rawUuid?.toByteArray()?.toByteString(),
pni = pni?.rawUuid?.toByteArray()?.toByteString(),
username = cursor.requireString(RecipientTable.USERNAME),
e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong(),
blocked = cursor.requireBoolean(RecipientTable.BLOCKED),
hidden = cursor.requireBoolean(RecipientTable.HIDDEN),
registered = registeredState.toContactRegisteredState(),
unregisteredTimestamp = cursor.requireLong(RecipientTable.UNREGISTERED_TIMESTAMP),
profileKey = if (profileKey != null) Base64.decode(profileKey).toByteString() else null,
profileSharing = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
profileGivenName = cursor.requireString(RecipientTable.PROFILE_GIVEN_NAME).nullIfBlank(),
profileFamilyName = cursor.requireString(RecipientTable.PROFILE_FAMILY_NAME).nullIfBlank(),
hideStory = extras?.hideStory() ?: false
)
contact = contactBuilder.build()
)
}
@@ -489,22 +504,6 @@ private fun String.e164ToLong(): Long? {
return fixed.toLongOrNull()
}
private fun RecipientTable.RegisteredState.toContactRegisteredState(): Contact.Registered {
return when (this) {
RecipientTable.RegisteredState.REGISTERED -> Contact.Registered.REGISTERED
RecipientTable.RegisteredState.NOT_REGISTERED -> Contact.Registered.NOT_REGISTERED
RecipientTable.RegisteredState.UNKNOWN -> Contact.Registered.UNKNOWN
}
}
private fun Contact.Registered.toLocalRegisteredState(): RecipientTable.RegisteredState {
return when (this) {
Contact.Registered.REGISTERED -> RecipientTable.RegisteredState.REGISTERED
Contact.Registered.NOT_REGISTERED -> RecipientTable.RegisteredState.NOT_REGISTERED
Contact.Registered.UNKNOWN -> RecipientTable.RegisteredState.UNKNOWN
}
}
private fun GroupTable.ShowAsStoryState.toGroupStorySendMode(): Group.StorySendMode {
return when (this) {
GroupTable.ShowAsStoryState.ALWAYS -> Group.StorySendMode.ENABLED

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.StickerTable
fun StickerTable.clearAllDataForBackupRestore() {
writableDatabase.deleteAll(StickerTable.TABLE_NAME)
}

View File

@@ -7,16 +7,23 @@ package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import androidx.core.content.contentValuesOf
import com.google.protobuf.InvalidProtocolBufferException
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.ChatStyle
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.model.databaseprotos.ChatColor
import org.thoughtcrime.securesms.recipients.RecipientId
import java.io.Closeable
@@ -33,7 +40,9 @@ fun ThreadTable.getThreadsForBackup(): ChatIterator {
${ThreadTable.ARCHIVED},
${RecipientTable.TABLE_NAME}.${RecipientTable.MESSAGE_EXPIRATION_TIME},
${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL},
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING}
${RecipientTable.TABLE_NAME}.${RecipientTable.MENTION_SETTING},
${RecipientTable.TABLE_NAME}.${RecipientTable.CHAT_COLORS},
${RecipientTable.TABLE_NAME}.${RecipientTable.CUSTOM_CHAT_COLORS_ID}
FROM ${ThreadTable.TABLE_NAME}
LEFT OUTER JOIN ${RecipientTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = ${RecipientTable.TABLE_NAME}.${RecipientTable.ID}
"""
@@ -49,6 +58,15 @@ fun ThreadTable.clearAllDataForBackupRestore() {
}
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
val chatColor = chat.style?.parseChatColor()
val chatColorWithId = if (chatColor != null && chatColor.id is ChatColors.Id.NotSet) {
val savedColors = SignalDatabase.chatColors.getSavedChatColors()
val match = savedColors.find { it.matchesWithoutId(chatColor) }
match ?: SignalDatabase.chatColors.saveChatColors(chatColor)
} else {
chatColor
}
val threadId = writableDatabase
.insertInto(ThreadTable.TABLE_NAME)
.values(
@@ -65,7 +83,9 @@ fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
contentValuesOf(
RecipientTable.MENTION_SETTING to (if (chat.dontNotifyForMentionsIfMuted) RecipientTable.MentionSetting.DO_NOT_NOTIFY.id else RecipientTable.MentionSetting.ALWAYS_NOTIFY.id),
RecipientTable.MUTE_UNTIL to chat.muteUntilMs,
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs
RecipientTable.MESSAGE_EXPIRATION_TIME to chat.expirationTimerMs,
RecipientTable.CHAT_COLORS to chatColorWithId?.serialize()?.encode(),
RecipientTable.CUSTOM_CHAT_COLORS_ID to (chatColorWithId?.id ?: ChatColors.Id.NotSet).longValue
),
"${RecipientTable.ID} = ?",
SqlUtil.buildArgs(recipientId.toLong())
@@ -84,6 +104,33 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
throw NoSuchElementException()
}
val serializedChatColors = cursor.requireBlob(RecipientTable.CHAT_COLORS)
val customChatColorsId = ChatColors.Id.forLongValue(cursor.requireLong(RecipientTable.CUSTOM_CHAT_COLORS_ID))
val chatColors: ChatColors? = if (serializedChatColors != null) {
try {
ChatColors.forChatColor(customChatColorsId, ChatColor.ADAPTER.decode(serializedChatColors))
} catch (e: InvalidProtocolBufferException) {
null
}
} else {
null
}
var chatStyleBuilder: ChatStyle.Builder? = null
if (chatColors != null) {
chatStyleBuilder = ChatStyle.Builder()
val presetBubbleColor = chatColors.tryToMapToBackupPreset()
if (presetBubbleColor != null) {
chatStyleBuilder.bubbleColorPreset = presetBubbleColor
} else if (chatColors.isGradient()) {
chatStyleBuilder.bubbleGradient = ChatStyle.Gradient(angle = chatColors.getDegrees().toInt(), colors = chatColors.getColors().toList())
} else if (customChatColorsId is ChatColors.Id.Auto) {
chatStyleBuilder.autoBubbleColor = ChatStyle.AutomaticBubbleColor()
} else {
chatStyleBuilder.bubbleSolidColor = chatColors.asSingleColor()
}
}
return Chat(
id = cursor.requireLong(ThreadTable.ID),
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
@@ -92,7 +139,8 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
expirationTimerMs = cursor.requireLong(RecipientTable.MESSAGE_EXPIRATION_TIME),
muteUntilMs = cursor.requireLong(RecipientTable.MUTE_UNTIL),
markedUnread = ThreadTable.ReadStatus.deserialize(cursor.requireInt(ThreadTable.READ)) == ThreadTable.ReadStatus.FORCED_UNREAD,
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING)
dontNotifyForMentionsIfMuted = RecipientTable.MentionSetting.DO_NOT_NOTIFY.id == cursor.requireInt(RecipientTable.MENTION_SETTING),
style = chatStyleBuilder?.build()
)
}
@@ -100,3 +148,79 @@ class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
cursor.close()
}
}
private fun ChatStyle.parseChatColor(): ChatColors? {
if (bubbleColorPreset != null) {
return when (bubbleColorPreset) {
ChatStyle.BubbleColorPreset.SOLID_CRIMSON -> ChatColorsPalette.Bubbles.CRIMSON
ChatStyle.BubbleColorPreset.SOLID_VERMILION -> ChatColorsPalette.Bubbles.VERMILION
ChatStyle.BubbleColorPreset.SOLID_BURLAP -> ChatColorsPalette.Bubbles.BURLAP
ChatStyle.BubbleColorPreset.SOLID_FOREST -> ChatColorsPalette.Bubbles.FOREST
ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN -> ChatColorsPalette.Bubbles.WINTERGREEN
ChatStyle.BubbleColorPreset.SOLID_TEAL -> ChatColorsPalette.Bubbles.TEAL
ChatStyle.BubbleColorPreset.SOLID_BLUE -> ChatColorsPalette.Bubbles.BLUE
ChatStyle.BubbleColorPreset.SOLID_INDIGO -> ChatColorsPalette.Bubbles.INDIGO
ChatStyle.BubbleColorPreset.SOLID_VIOLET -> ChatColorsPalette.Bubbles.VIOLET
ChatStyle.BubbleColorPreset.SOLID_PLUM -> ChatColorsPalette.Bubbles.PLUM
ChatStyle.BubbleColorPreset.SOLID_TAUPE -> ChatColorsPalette.Bubbles.TAUPE
ChatStyle.BubbleColorPreset.SOLID_STEEL -> ChatColorsPalette.Bubbles.STEEL
ChatStyle.BubbleColorPreset.GRADIENT_EMBER -> ChatColorsPalette.Bubbles.EMBER
ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT -> ChatColorsPalette.Bubbles.MIDNIGHT
ChatStyle.BubbleColorPreset.GRADIENT_INFRARED -> ChatColorsPalette.Bubbles.INFRARED
ChatStyle.BubbleColorPreset.GRADIENT_LAGOON -> ChatColorsPalette.Bubbles.LAGOON
ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT -> ChatColorsPalette.Bubbles.FLUORESCENT
ChatStyle.BubbleColorPreset.GRADIENT_BASIL -> ChatColorsPalette.Bubbles.BASIL
ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME -> ChatColorsPalette.Bubbles.SUBLIME
ChatStyle.BubbleColorPreset.GRADIENT_SEA -> ChatColorsPalette.Bubbles.SEA
ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE -> ChatColorsPalette.Bubbles.TANGERINE
ChatStyle.BubbleColorPreset.UNKNOWN_BUBBLE_COLOR_PRESET, ChatStyle.BubbleColorPreset.SOLID_ULTRAMARINE -> ChatColorsPalette.Bubbles.ULTRAMARINE
}
}
if (autoBubbleColor != null) {
return ChatColorsPalette.Bubbles.default.withId(ChatColors.Id.Auto)
}
if (bubbleSolidColor != null) {
return ChatColors(id = ChatColors.Id.NotSet, singleColor = bubbleSolidColor, linearGradient = null)
}
if (bubbleGradient != null) {
return ChatColors(
id = ChatColors.Id.NotSet,
singleColor = null,
linearGradient = ChatColors.LinearGradient(
degrees = bubbleGradient.angle.toFloat(),
colors = bubbleGradient.colors.toIntArray(),
positions = floatArrayOf(0f, 1f)
)
)
}
return null
}
private fun ChatColors.tryToMapToBackupPreset(): ChatStyle.BubbleColorPreset? {
when (this) {
// Solids
ChatColorsPalette.Bubbles.CRIMSON -> return ChatStyle.BubbleColorPreset.SOLID_CRIMSON
ChatColorsPalette.Bubbles.VERMILION -> return ChatStyle.BubbleColorPreset.SOLID_VERMILION
ChatColorsPalette.Bubbles.BURLAP -> return ChatStyle.BubbleColorPreset.SOLID_BURLAP
ChatColorsPalette.Bubbles.FOREST -> return ChatStyle.BubbleColorPreset.SOLID_FOREST
ChatColorsPalette.Bubbles.WINTERGREEN -> return ChatStyle.BubbleColorPreset.SOLID_WINTERGREEN
ChatColorsPalette.Bubbles.TEAL -> return ChatStyle.BubbleColorPreset.SOLID_TEAL
ChatColorsPalette.Bubbles.BLUE -> return ChatStyle.BubbleColorPreset.SOLID_BLUE
ChatColorsPalette.Bubbles.INDIGO -> return ChatStyle.BubbleColorPreset.SOLID_INDIGO
ChatColorsPalette.Bubbles.VIOLET -> return ChatStyle.BubbleColorPreset.SOLID_VIOLET
ChatColorsPalette.Bubbles.PLUM -> return ChatStyle.BubbleColorPreset.SOLID_PLUM
ChatColorsPalette.Bubbles.TAUPE -> return ChatStyle.BubbleColorPreset.SOLID_TAUPE
ChatColorsPalette.Bubbles.STEEL -> return ChatStyle.BubbleColorPreset.SOLID_STEEL
// Gradients
ChatColorsPalette.Bubbles.EMBER -> return ChatStyle.BubbleColorPreset.GRADIENT_EMBER
ChatColorsPalette.Bubbles.MIDNIGHT -> return ChatStyle.BubbleColorPreset.GRADIENT_MIDNIGHT
ChatColorsPalette.Bubbles.INFRARED -> return ChatStyle.BubbleColorPreset.GRADIENT_INFRARED
ChatColorsPalette.Bubbles.LAGOON -> return ChatStyle.BubbleColorPreset.GRADIENT_LAGOON
ChatColorsPalette.Bubbles.FLUORESCENT -> return ChatStyle.BubbleColorPreset.GRADIENT_FLUORESCENT
ChatColorsPalette.Bubbles.BASIL -> return ChatStyle.BubbleColorPreset.GRADIENT_BASIL
ChatColorsPalette.Bubbles.SUBLIME -> return ChatStyle.BubbleColorPreset.GRADIENT_SUBLIME
ChatColorsPalette.Bubbles.SEA -> return ChatStyle.BubbleColorPreset.GRADIENT_SEA
ChatColorsPalette.Bubbles.TANGERINE -> return ChatStyle.BubbleColorPreset.GRADIENT_TANGERINE
}
return null
}

View File

@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
@@ -29,47 +28,50 @@ import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
import org.whispersystems.signalservice.api.util.UuidUtil
import kotlin.jvm.optionals.getOrNull
import java.util.Currency
object AccountDataProcessor {
fun export(emitter: BackupFrameEmitter) {
fun export(db: SignalDatabase, signalStore: SignalStore, emitter: BackupFrameEmitter) {
val context = AppDependencies.application
val self = Recipient.self().fresh()
val record = recipients.getRecordForSync(self.id)
val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get()
val selfRecord = db.recipientTable.getRecordForSync(selfId)!!
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
val donationCurrency = signalStore.inAppPaymentValues.getSubscriptionCurrency(InAppPaymentSubscriberRecord.Type.DONATION)
val donationSubscriber = db.inAppPaymentSubscriberTable.getByCurrencyCode(donationCurrency.currencyCode, InAppPaymentSubscriberRecord.Type.DONATION)
emitter.emit(
Frame(
account = AccountData(
profileKey = self.profileKey?.toByteString() ?: EMPTY,
givenName = self.profileName.givenName,
familyName = self.profileName.familyName,
avatarUrlPath = self.profileAvatar ?: "",
subscriptionManuallyCancelled = InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION),
username = self.username.getOrNull(),
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
profileKey = selfRecord.profileKey?.toByteString() ?: EMPTY,
givenName = selfRecord.signalProfileName.givenName,
familyName = selfRecord.signalProfileName.familyName,
avatarUrlPath = selfRecord.signalProfileAvatar ?: "",
username = selfRecord.username,
accountSettings = AccountData.AccountSettings(
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
storyViewReceiptsEnabled = signalStore.storyValues.viewedReceiptsEnabled,
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
linkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
notDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE,
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
universalExpireTimer = SignalStore.settings().universalExpireTimer,
preferredReactionEmoji = SignalStore.emojiValues().rawReactions,
storiesDisabled = SignalStore.storyValues().isFeatureDisabled,
hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory,
hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories,
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(),
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = SignalStore.uiHints().hasCompletedUsernameOnboarding()
linkPreviews = signalStore.settingsValues.isLinkPreviewsEnabled,
notDiscoverableByPhoneNumber = signalStore.phoneNumberPrivacyValues.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE,
phoneNumberSharingMode = signalStore.phoneNumberPrivacyValues.phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
preferContactAvatars = signalStore.settingsValues.isPreferSystemContactPhotos,
universalExpireTimer = signalStore.settingsValues.universalExpireTimer,
preferredReactionEmoji = signalStore.emojiValues.rawReactions,
storiesDisabled = signalStore.storyValues.isFeatureDisabled,
hasViewedOnboardingStory = signalStore.storyValues.userHasViewedOnboardingStory,
hasSetMyStoriesPrivacy = signalStore.storyValues.userHasBeenNotifiedAboutStories,
keepMutedChatsArchived = signalStore.settingsValues.shouldKeepMutedChatsArchived(),
displayBadgesOnProfile = signalStore.inAppPaymentValues.getDisplayBadgesOnProfile(),
hasSeenGroupStoryEducationSheet = signalStore.storyValues.userHasSeenGroupStoryEducationSheet,
hasCompletedUsernameOnboarding = signalStore.uiHintValues.hasCompletedUsernameOnboarding()
),
donationSubscriberData = AccountData.SubscriberData(
subscriberId = donationSubscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
currencyCode = donationSubscriber?.currency?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
manuallyCancelled = signalStore.inAppPaymentValues.isDonationSubscriptionManuallyCancelled()
)
)
)
@@ -77,9 +79,9 @@ object AccountDataProcessor {
}
fun import(accountData: AccountData, selfId: RecipientId) {
recipients.restoreSelfFromBackup(accountData, selfId)
SignalDatabase.recipients.restoreSelfFromBackup(accountData, selfId)
SignalStore.account().setRegistered(true)
SignalStore.account.setRegistered(true)
val context = AppDependencies.application
val settings = accountData.accountSettings
@@ -88,37 +90,39 @@ object AccountDataProcessor {
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews
SignalStore.phoneNumberPrivacy().phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
SignalStore.settings().isPreferSystemContactPhotos = settings.preferContactAvatars
SignalStore.settings().universalExpireTimer = settings.universalExpireTimer
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
SignalStore.donationsValues().setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
SignalStore.settings().setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
SignalStore.storyValues().userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
SignalStore.storyValues().userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
SignalStore.storyValues().isFeatureDisabled = settings.storiesDisabled
SignalStore.storyValues().userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.storyValues().viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
SignalStore.settings.isLinkPreviewsEnabled = settings.linkPreviews
SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE
SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = settings.phoneNumberSharingMode.toLocalPhoneNumberMode()
SignalStore.settings.isPreferSystemContactPhotos = settings.preferContactAvatars
SignalStore.settings.universalExpireTimer = settings.universalExpireTimer
SignalStore.emoji.reactions = settings.preferredReactionEmoji
SignalStore.inAppPayments.setDisplayBadgesOnProfile(settings.displayBadgesOnProfile)
SignalStore.settings.setKeepMutedChatsArchived(settings.keepMutedChatsArchived)
SignalStore.story.userHasBeenNotifiedAboutStories = settings.hasSetMyStoriesPrivacy
SignalStore.story.userHasViewedOnboardingStory = settings.hasViewedOnboardingStory
SignalStore.story.isFeatureDisabled = settings.storiesDisabled
SignalStore.story.userHasSeenGroupStoryEducationSheet = settings.hasSeenGroupStoryEducationSheet
SignalStore.story.viewedReceiptsEnabled = settings.storyViewReceiptsEnabled ?: settings.readReceipts
if (accountData.subscriberId.size > 0) {
val remoteSubscriberId = SubscriberId.fromBytes(accountData.subscriberId.toByteArray())
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
if (accountData.donationSubscriberData != null) {
if (accountData.donationSubscriberData.subscriberId.size > 0) {
val remoteSubscriberId = SubscriberId.fromBytes(accountData.donationSubscriberData.subscriberId.toByteArray())
val localSubscriber = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
val subscriber = InAppPaymentSubscriberRecord(
remoteSubscriberId,
accountData.subscriberCurrencyCode,
InAppPaymentSubscriberRecord.Type.DONATION,
localSubscriber?.requiresCancel ?: false,
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
)
val subscriber = InAppPaymentSubscriberRecord(
remoteSubscriberId,
Currency.getInstance(accountData.donationSubscriberData.currencyCode),
InAppPaymentSubscriberRecord.Type.DONATION,
localSubscriber?.requiresCancel ?: accountData.donationSubscriberData.manuallyCancelled,
InAppPaymentsRepository.getLatestPaymentMethodType(InAppPaymentSubscriberRecord.Type.DONATION)
)
InAppPaymentsRepository.setSubscriber(subscriber)
}
InAppPaymentsRepository.setSubscriber(subscriber)
}
if (accountData.subscriptionManuallyCancelled) {
SignalStore.donationsValues().updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
if (accountData.donationSubscriberData.manuallyCancelled) {
SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION)
}
}
if (accountData.avatarUrlPath.isNotEmpty()) {
@@ -126,19 +130,19 @@ object AccountDataProcessor {
}
if (accountData.usernameLink != null) {
SignalStore.account().usernameLink = UsernameLinkComponents(
SignalStore.account.usernameLink = UsernameLinkComponents(
accountData.usernameLink.entropy.toByteArray(),
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
)
SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
SignalStore.misc.usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
}
if (settings.preferredReactionEmoji.isNotEmpty()) {
SignalStore.emojiValues().reactions = settings.preferredReactionEmoji
SignalStore.emoji.reactions = settings.preferredReactionEmoji
}
if (settings.hasCompletedUsernameOnboarding) {
SignalStore.uiHints().setHasCompletedUsernameOnboarding(true)
SignalStore.uiHints.setHasCompletedUsernameOnboarding(true)
}
}

View File

@@ -18,8 +18,8 @@ object AdHocCallBackupProcessor {
val TAG = Log.tag(AdHocCallBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
SignalDatabase.calls.getAdhocCallsForBackup().use { reader ->
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
db.callTable.getAdhocCallsForBackup().use { reader ->
for (callLog in reader) {
if (callLog != null) {
emitter.emit(Frame(adHocCall = callLog))

View File

@@ -19,8 +19,8 @@ import org.thoughtcrime.securesms.recipients.RecipientId
object ChatBackupProcessor {
val TAG = Log.tag(ChatBackupProcessor::class.java)
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.threads.getThreadsForBackup().use { reader ->
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
db.threadTable.getThreadsForBackup().use { reader ->
for (chat in reader) {
if (exportState.recipientIds.contains(chat.recipientId)) {
exportState.threadIds.add(chat.id)

View File

@@ -18,11 +18,14 @@ import org.thoughtcrime.securesms.database.SignalDatabase
object ChatItemBackupProcessor {
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
fun export(exportState: ExportState, emitter: BackupFrameEmitter) {
SignalDatabase.messages.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
for (chatItem in chatItems) {
if (exportState.threadIds.contains(chatItem.chatId)) {
emitter.emit(Frame(chatItem = chatItem))
fun export(db: SignalDatabase, exportState: ExportState, emitter: BackupFrameEmitter) {
db.messageTable.getMessagesForBackup(exportState.backupTime, exportState.allowMediaBackup).use { chatItems ->
while (chatItems.hasNext()) {
val chatItem = chatItems.next()
if (chatItem != null) {
if (exportState.threadIds.contains(chatItem.chatId)) {
emitter.emit(Frame(chatItem = chatItem))
}
}
}
}

View File

@@ -24,15 +24,13 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
object RecipientBackupProcessor {
val TAG = Log.tag(RecipientBackupProcessor::class.java)
fun export(state: ExportState, emitter: BackupFrameEmitter) {
val selfId = Recipient.self().id.toLong()
val releaseChannelId = SignalStore.releaseChannelValues().releaseChannelRecipientId
fun export(db: SignalDatabase, signalStore: SignalStore, state: ExportState, emitter: BackupFrameEmitter) {
val selfId = db.recipientTable.getByAci(signalStore.accountValues.aci!!).get().toLong()
val releaseChannelId = signalStore.releaseChannelValues.releaseChannelRecipientId
if (releaseChannelId != null) {
emitter.emit(
Frame(
@@ -44,7 +42,7 @@ object RecipientBackupProcessor {
)
}
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
db.recipientTable.getContactsForBackup(selfId).use { reader ->
for (backupRecipient in reader) {
if (backupRecipient != null) {
state.recipientIds.add(backupRecipient.id)
@@ -53,19 +51,19 @@ object RecipientBackupProcessor {
}
}
SignalDatabase.recipients.getGroupsForBackup().use { reader ->
db.recipientTable.getGroupsForBackup().use { reader ->
for (backupRecipient in reader) {
state.recipientIds.add(backupRecipient.id)
emitter.emit(Frame(recipient = backupRecipient))
}
}
SignalDatabase.distributionLists.getAllForBackup().forEach {
db.distributionListTables.getAllForBackup().forEach {
state.recipientIds.add(it.id)
emitter.emit(Frame(recipient = it))
}
SignalDatabase.callLinks.getCallLinksForBackup().forEach {
db.callLinkTable.getCallLinksForBackup().forEach {
state.recipientIds.add(it.id)
emitter.emit(Frame(recipient = it))
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.proto.StickerPack
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.StickerTable.StickerPackRecordReader
import org.thoughtcrime.securesms.database.model.StickerPackRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.StickerPackDownloadJob
object StickerBackupProcessor {
fun export(db: SignalDatabase, emitter: BackupFrameEmitter) {
StickerPackRecordReader(db.stickerTable.allStickerPacks).use { reader ->
var record: StickerPackRecord? = reader.next
while (record != null) {
if (record.isInstalled) {
val frame = record.toBackupFrame()
emitter.emit(frame)
}
record = reader.next
}
}
}
fun import(stickerPack: StickerPack) {
AppDependencies.jobManager.add(
StickerPackDownloadJob.forInstall(Hex.toStringCondensed(stickerPack.packId.toByteArray()), Hex.toStringCondensed(stickerPack.packKey.toByteArray()), false)
)
}
}
private fun StickerPackRecord.toBackupFrame(): Frame {
val packIdBytes = Hex.fromStringCondensed(packId)
val packKey = Hex.fromStringCondensed(packKey)
val pack = StickerPack(
packId = packIdBytes.toByteString(),
packKey = packKey.toByteString()
)
return Frame(stickerPack = pack)
}

View File

@@ -10,4 +10,6 @@ import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupImportReader : Iterator<Frame>, AutoCloseable {
fun getHeader(): BackupInfo?
fun getBytesRead(): Long
fun getStreamLength(): Long
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.backup.v2.stream
import com.google.common.io.CountingInputStream
import org.signal.core.util.readFully
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
@@ -32,21 +33,22 @@ import javax.crypto.spec.SecretKeySpec
class EncryptedBackupReader(
key: BackupKey,
aci: ACI,
streamLength: Long,
val length: Long,
dataStream: () -> InputStream
) : BackupImportReader {
val backupInfo: BackupInfo?
var next: Frame? = null
val stream: InputStream
val countingStream: CountingInputStream
init {
val keyMaterial = key.deriveBackupSecrets(aci)
validateMac(keyMaterial.macKey, streamLength, dataStream())
validateMac(keyMaterial.macKey, length, dataStream())
val inputStream = dataStream()
val iv = inputStream.readNBytesOrThrow(16)
countingStream = CountingInputStream(dataStream())
val iv = countingStream.readNBytesOrThrow(16)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(iv))
@@ -55,8 +57,8 @@ class EncryptedBackupReader(
stream = GZIPInputStream(
CipherInputStream(
TruncatingInputStream(
wrapped = inputStream,
maxBytes = streamLength - MAC_SIZE
wrapped = countingStream,
maxBytes = length - MAC_SIZE
),
cipher
)
@@ -69,6 +71,10 @@ class EncryptedBackupReader(
return backupInfo
}
override fun getBytesRead() = countingStream.count
override fun getStreamLength() = length
override fun hasNext(): Boolean {
return next != null
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.backup.v2.stream
import com.google.common.io.CountingInputStream
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.BackupInfo
@@ -15,12 +16,14 @@ import java.io.InputStream
/**
* Reads a plaintext backup import stream one frame at a time.
*/
class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader {
class PlainTextBackupReader(val dataStream: InputStream, val length: Long) : BackupImportReader {
val backupInfo: BackupInfo?
var next: Frame? = null
val inputStream: CountingInputStream
init {
inputStream = CountingInputStream(dataStream)
backupInfo = readHeader()
next = read()
}
@@ -29,6 +32,10 @@ class PlainTextBackupReader(val inputStream: InputStream) : BackupImportReader {
return backupInfo
}
override fun getBytesRead() = inputStream.count
override fun getStreamLength() = length
override fun hasNext(): Boolean {
return next != null
}

View File

@@ -25,7 +25,9 @@ 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.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
@@ -37,6 +39,7 @@ import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
/**
* Notifies the user of an issue with their backup.
@@ -69,18 +72,17 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
@Stable
private fun performPrimaryAction() {
when (backupAlert) {
BackupAlert.GENERIC -> {
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> {
// TODO [message-backups] -- Back up now
}
BackupAlert.PAYMENT_PROCESSING -> {
// TODO [message-backups] -- Silence
}
BackupAlert.PAYMENT_PROCESSING -> Unit
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> {
// TODO [message-backups] -- Download media now
}
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] -- Download media now
}
BackupAlert.DISK_FULL -> Unit
}
dismissAllowingStateLoss()
@@ -89,7 +91,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
@Stable
private fun performSecondaryAction() {
when (backupAlert) {
BackupAlert.GENERIC -> {
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> {
// TODO [message-backups] - Dismiss and notify later
}
BackupAlert.PAYMENT_PROCESSING -> error("PAYMENT_PROCESSING state does not support a secondary action.")
@@ -99,6 +101,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() {
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> {
// TODO [message-backups] - Silence forever
}
BackupAlert.DISK_FULL -> Unit
}
dismissAllowingStateLoss()
@@ -139,10 +142,18 @@ private fun BackupAlertSheetContent(
)
when (backupAlert) {
BackupAlert.GENERIC -> GenericBody()
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody()
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody()
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> CouldNotCompleteBackup(
daysSinceLastBackup = 7 // TODO [message-backups]
)
BackupAlert.PAYMENT_PROCESSING -> PaymentProcessingBody(
paymentMethodType = InAppPaymentData.PaymentMethodType.GOOGLE_PAY // TODO [message-backups] -- Get this data from elsewhere... The active subscription object?
)
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> MediaBackupsAreOffBody(30) // TODO [message-backups] -- Get this value from backend
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> MediaWillBeDeletedTodayBody()
BackupAlert.DISK_FULL -> DiskFullBody(
requiredSpace = "12 GB", // TODO [message-backups] Where does this value come from?
daysUntilDeletion = 30 // TODO [message-backups] Where does this value come from?
)
}
val secondaryActionResource = rememberSecondaryActionResource(backupAlert = backupAlert)
@@ -152,7 +163,7 @@ private fun BackupAlertSheetContent(
onClick = onPrimaryActionClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 60.dp, bottom = padBottom)
.padding(bottom = padBottom)
) {
Text(text = stringResource(id = rememberPrimaryActionResource(backupAlert = backupAlert)))
}
@@ -166,30 +177,79 @@ private fun BackupAlertSheetContent(
}
@Composable
private fun GenericBody() {
Text(text = "TODO")
private fun CouldNotCompleteBackup(
daysSinceLastBackup: Int
) {
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__your_device_hasnt, daysSinceLastBackup),
modifier = Modifier.padding(bottom = 60.dp)
)
}
@Composable
private fun PaymentProcessingBody() {
Text(text = "TODO")
private fun PaymentProcessingBody(paymentMethodType: InAppPaymentData.PaymentMethodType) {
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__were_having_trouble_collecting__google_pay),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 60.dp)
)
}
@Composable
private fun MediaBackupsAreOffBody() {
Text(text = "TODO")
private fun MediaBackupsAreOffBody(
daysUntilDeletion: Long
) {
Text(
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_signal_media_backup_plan, daysUntilDeletion.toInt(), daysUntilDeletion),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun MediaWillBeDeletedTodayBody() {
Text(text = "TODO")
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__your_signal_media_backup_plan_has_been),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__you_can_begin_paying_for_backups_again),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun DiskFullBody(
requiredSpace: String,
daysUntilDeletion: Long
) {
Text(
text = stringResource(id = R.string.BackupAlertBottomSheet__your_device_does_not_have_enough_free_space, requiredSpace),
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp)
)
Text(
text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__if_you_choose_skip, daysUntilDeletion.toInt(), daysUntilDeletion), // TODO [message-backups] Learn More link
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 36.dp)
)
}
@Composable
private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC, BackupAlert.PAYMENT_PROCESSING -> BackupsIconColors.Warning
BackupAlert.COULD_NOT_COMPLETE_BACKUP, BackupAlert.PAYMENT_PROCESSING, BackupAlert.DISK_FULL -> BackupsIconColors.Warning
BackupAlert.MEDIA_BACKUPS_ARE_OFF, BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> BackupsIconColors.Error
}
}
@@ -200,10 +260,11 @@ private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColo
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
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> R.string.BackupAlertBottomSheet__couldnt_complete_backup
BackupAlert.PAYMENT_PROCESSING -> R.string.BackupAlertBottomSheet__cant_process_backup_payment
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__media_backups_are_off
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today
BackupAlert.DISK_FULL -> R.string.BackupAlertBottomSheet__cant_complete_download
}
}
}
@@ -212,10 +273,11 @@ private fun rememberTitleResource(backupAlert: BackupAlert): Int {
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
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> android.R.string.ok // TODO [message-backups] -- Finalized copy
BackupAlert.PAYMENT_PROCESSING -> android.R.string.ok
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__download_media_now
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__download_media_now
BackupAlert.DISK_FULL -> android.R.string.ok
}
}
}
@@ -224,10 +286,11 @@ private fun rememberPrimaryActionResource(backupAlert: BackupAlert): Int {
private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
return remember(backupAlert) {
when (backupAlert) {
BackupAlert.GENERIC -> android.R.string.cancel // TODO [message-backups] -- Finalized copy
BackupAlert.COULD_NOT_COMPLETE_BACKUP -> 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
BackupAlert.MEDIA_BACKUPS_ARE_OFF -> R.string.BackupAlertBottomSheet__download_later
BackupAlert.MEDIA_WILL_BE_DELETED_TODAY -> R.string.BackupAlertBottomSheet__dont_download_media
BackupAlert.DISK_FULL -> R.string.BackupAlertBottomSheet__skip
}
}
}
@@ -237,7 +300,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int {
private fun BackupAlertSheetContentPreviewGeneric() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.GENERIC,
backupAlert = BackupAlert.COULD_NOT_COMPLETE_BACKUP,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
@@ -280,10 +343,23 @@ private fun BackupAlertSheetContentPreviewDelete() {
}
}
@SignalPreview
@Composable
private fun BackupAlertSheetContentPreviewDiskFull() {
Previews.BottomSheetPreview {
BackupAlertSheetContent(
backupAlert = BackupAlert.DISK_FULL,
onPrimaryActionClick = {},
onSecondaryActionClick = {}
)
}
}
@Parcelize
enum class BackupAlert : Parcelable {
GENERIC,
COULD_NOT_COMPLETE_BACKUP,
PAYMENT_PROCESSING,
MEDIA_BACKUPS_ARE_OFF,
MEDIA_WILL_BE_DELETED_TODAY
MEDIA_WILL_BE_DELETED_TODAY,
DISK_FULL
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
/**
* Delegate that controls whether and which backup alert sheet is displayed.
*/
object BackupAlertDelegate {
@JvmStatic
fun delegate(fragmentManager: FragmentManager, lifecycle: Lifecycle) {
lifecycle.coroutineScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
// TODO [message-backups]
// 1. Get unnotified backup upload failures
// 2. Get unnotified backup download failures
// 3. Get unnotified backup payment failures
// Decide which do display
}
}
}
}

View File

@@ -5,7 +5,6 @@
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
@@ -27,11 +26,11 @@ 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.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import kotlin.math.max
@@ -71,13 +70,13 @@ fun BackupStatus(
.weight(1f)
) {
Text(
text = stringResource(id = data.titleRes),
text = data.title,
style = MaterialTheme.typography.bodyMedium
)
if (data.progress >= 0f) {
LinearProgressIndicator(
progress = data.progress,
progress = { data.progress },
strokeCap = StrokeCap.Round,
modifier = Modifier
.fillMaxWidth()
@@ -105,8 +104,7 @@ fun BackupStatus(
}
}
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@SignalPreview
@Composable
fun BackupStatusPreview() {
Previews.Preview {
@@ -118,7 +116,7 @@ fun BackupStatusPreview() {
)
BackupStatus(
data = BackupStatusData.NotEnoughFreeSpace
data = BackupStatusData.NotEnoughFreeSpace("12 GB")
)
BackupStatus(
@@ -138,8 +136,8 @@ sealed interface BackupStatusData {
@get:DrawableRes
val iconRes: Int
@get:StringRes
val titleRes: Int
@get:Composable
val title: String
val iconColors: BackupsIconColors
@@ -154,18 +152,28 @@ sealed interface BackupStatusData {
/**
* Generic failure
*/
object CouldNotCompleteBackup : BackupStatusData {
data object CouldNotCompleteBackup : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val titleRes: Int = R.string.default_error_msg
override val title: String
@Composable
get() = stringResource(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 {
class NotEnoughFreeSpace(
private val requiredSpace: String
) : BackupStatusData {
override val iconRes: Int = R.drawable.symbol_backup_light
override val titleRes: Int = R.string.default_error_msg
override val title: String
@Composable
get() = stringResource(R.string.BackupStatus__free_up_s_of_space_to_download_your_media, requiredSpace)
override val iconColors: BackupsIconColors = BackupsIconColors.Warning
override val actionRes: Int = R.string.registration_activity__skip
}
@@ -181,13 +189,16 @@ sealed interface 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 title: String
@Composable get() = stringResource(
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

View File

@@ -0,0 +1,104 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.thoughtcrime.securesms.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConfirmBackupCancellationDialog(
onConfirmAndDownloadNow: () -> Unit,
onConfirmAndDownloadLater: () -> Unit,
onKeepSubscriptionClick: () -> Unit
) {
BasicAlertDialog(onDismissRequest = onKeepSubscriptionClick) {
Surface(
shape = AlertDialogDefaults.shape,
color = AlertDialogDefaults.containerColor
) {
Column {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_cancellation),
color = AlertDialogDefaults.titleContentColor,
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier
.padding(top = 24.dp)
.padding(horizontal = 24.dp)
)
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__you_wont_be_charged_again),
color = AlertDialogDefaults.textContentColor,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 24.dp)
)
TextButton(
onClick = onConfirmAndDownloadNow,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_now)
)
}
TextButton(
onClick = onConfirmAndDownloadLater,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__confirm_and_download_later)
)
}
TextButton(
onClick = onKeepSubscriptionClick,
modifier = Modifier
.align(Alignment.End)
.padding(end = 12.dp, bottom = 12.dp)
) {
Text(
text = stringResource(id = R.string.ConfirmBackupCancellationDialog__keep_subscription)
)
}
}
}
}
}
@SignalPreview
@Composable
private fun ConfirmCancellationDialogPreview() {
Previews.Preview {
ConfirmBackupCancellationDialog(
onKeepSubscriptionClick = {},
onConfirmAndDownloadNow = {},
onConfirmAndDownloadLater = {}
)
}
}

View File

@@ -11,12 +11,14 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -30,49 +32,59 @@ 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.models.GooglePayButton
import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData
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(
messageBackupTier: MessageBackupTier,
messageBackupsType: MessageBackupsType,
availablePaymentMethods: List<InAppPaymentData.PaymentMethodType>,
sheetState: SheetState,
onDismissRequest: () -> Unit,
onPaymentMethodSelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
dragHandle = { BottomSheets.Handle() },
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
modifier = Modifier.padding()
) {
SheetContent(
messageBackupTier = messageBackupTier,
availablePaymentGateways = availablePaymentMethods,
onPaymentGatewaySelected = onPaymentMethodSelected
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
.navigationBarsPadding()
) {
SheetContent(
messageBackupsType = messageBackupsType,
availablePaymentGateways = availablePaymentMethods,
onPaymentGatewaySelected = onPaymentMethodSelected
)
}
}
}
@Composable
private fun SheetContent(
messageBackupTier: MessageBackupTier,
messageBackupsType: MessageBackupsType,
availablePaymentGateways: List<InAppPaymentData.PaymentMethodType>,
onPaymentGatewaySelected: (InAppPaymentData.PaymentMethodType) -> Unit
) {
val resources = LocalContext.current.resources
val backupTypeDetails = remember(messageBackupTier) {
getTierDetails(messageBackupTier)
}
val formattedPrice = remember(backupTypeDetails.pricePerMonth) {
FiatMoneyUtil.format(resources, backupTypeDetails.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
val formattedPrice = remember(messageBackupsType.pricePerMonth) {
FiatMoneyUtil.format(resources, messageBackupsType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
}
Text(
@@ -88,7 +100,8 @@ private fun SheetContent(
)
MessageBackupsTypeBlock(
messageBackupsType = backupTypeDetails,
messageBackupsType = messageBackupsType,
isCurrent = false,
isSelected = false,
onSelected = {},
enabled = false,
@@ -231,7 +244,12 @@ private fun MessageBackupsCheckoutSheetPreview() {
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
) {
SheetContent(
messageBackupTier = MessageBackupTier.PAID,
messageBackupsType = MessageBackupsType(
tier = MessageBackupTier.FREE,
title = "Free",
pricePerMonth = FiatMoney(BigDecimal.ZERO, Currency.getInstance("USD")),
features = persistentListOf()
),
availablePaymentGateways = availablePaymentGateways,
onPaymentGatewaySelected = {}
)

View File

@@ -1,119 +0,0 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.dialog
import androidx.navigation.compose.rememberNavController
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.util.viewModel
class MessageBackupsFlowActivity : PassphraseRequiredActivity() {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
setContent {
SignalTheme {
val state by viewModel.state
val navController = rememberNavController()
fun MessageBackupsScreen.next() {
val nextScreen = viewModel.goToNextScreen(this)
if (nextScreen == MessageBackupsScreen.COMPLETED) {
finishAfterTransition()
return
}
if (nextScreen != this) {
navController.navigate(nextScreen.name)
}
}
fun NavController.popOrFinish() {
if (popBackStack()) {
return
}
finishAfterTransition()
}
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowActivity)
navController.setOnBackPressedDispatcher(this@MessageBackupsFlowActivity.onBackPressedDispatcher)
navController.enableOnBackPressed(true)
}
NavHost(
navController = navController,
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 }) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = navController::popOrFinish,
onEnableBackups = { MessageBackupsScreen.EDUCATION.next() },
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = navController::popOrFinish,
onGeneratePinClick = {},
onUseCurrentPinClick = { MessageBackupsScreen.PIN_EDUCATION.next() },
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = state.pin,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = { MessageBackupsScreen.PIN_CONFIRMATION.next() }
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTiers = state.availableBackupTiers,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = navController::popOrFinish,
onReadMoreClicked = {},
onNextClicked = { MessageBackupsScreen.TYPE_SELECTION.next() }
)
}
dialog(route = MessageBackupsScreen.CHECKOUT_SHEET.name) {
MessageBackupsCheckoutSheet(
messageBackupTier = state.selectedMessageBackupTier!!,
availablePaymentMethods = state.availablePaymentMethods,
onDismissRequest = navController::popOrFinish,
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
MessageBackupsScreen.CHECKOUT_SHEET.next()
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,260 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.ui.subscription
import androidx.activity.OnBackPressedCallback
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.fragment.findNavController
import io.reactivex.rxjava3.processors.PublishProcessor
import org.signal.donations.InAppPaymentType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.InAppPaymentProcessorAction
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.Nav
import org.thoughtcrime.securesms.database.InAppPaymentTable
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
/**
* Handles the selection, payment, and changing of a user's backup tier.
*/
class MessageBackupsFlowFragment : ComposeFragment(), InAppPaymentCheckoutDelegate.Callback {
private val viewModel: MessageBackupsFlowViewModel by viewModel { MessageBackupsFlowViewModel() }
private val inAppPaymentIdProcessor = PublishProcessor.create<InAppPaymentTable.InAppPaymentId>()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun FragmentContent() {
val state by viewModel.stateFlow.collectAsState()
val pin by viewModel.pinState
val navController = rememberNavController()
val checkoutDelegate = remember {
InAppPaymentCheckoutDelegate(this, this, inAppPaymentIdProcessor)
}
LaunchedEffect(state.inAppPayment?.id) {
val inAppPaymentId = state.inAppPayment?.id
if (inAppPaymentId != null) {
inAppPaymentIdProcessor.onNext(inAppPaymentId)
}
}
val checkoutSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
LaunchedEffect(Unit) {
navController.setLifecycleOwner(this@MessageBackupsFlowFragment)
requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
viewModel.goToPreviousScreen()
}
})
}
Nav.Host(
navController = navController,
startDestination = state.startScreen.name
) {
composable(route = MessageBackupsScreen.EDUCATION.name) {
MessageBackupsEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onEnableBackups = viewModel::goToNextScreen,
onLearnMore = {}
)
}
composable(route = MessageBackupsScreen.PIN_EDUCATION.name) {
MessageBackupsPinEducationScreen(
onNavigationClick = viewModel::goToPreviousScreen,
onGeneratePinClick = {},
onUseCurrentPinClick = viewModel::goToNextScreen,
recommendedPinSize = 16 // TODO [message-backups] This value should come from some kind of config
)
}
composable(route = MessageBackupsScreen.PIN_CONFIRMATION.name) {
MessageBackupsPinConfirmationScreen(
pin = pin,
onPinChanged = viewModel::onPinEntryUpdated,
pinKeyboardType = state.pinKeyboardType,
onPinKeyboardTypeSelected = viewModel::onPinKeyboardTypeUpdated,
onNextClick = viewModel::goToNextScreen
)
}
composable(route = MessageBackupsScreen.TYPE_SELECTION.name) {
MessageBackupsTypeSelectionScreen(
currentBackupTier = state.currentMessageBackupTier,
selectedBackupTier = state.selectedMessageBackupTier,
availableBackupTypes = state.availableBackupTypes,
onMessageBackupsTierSelected = viewModel::onMessageBackupTierUpdated,
onNavigationClick = viewModel::goToPreviousScreen,
onReadMoreClicked = {},
onCancelSubscriptionClicked = viewModel::displayCancellationDialog,
onNextClicked = viewModel::goToNextScreen
)
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
MessageBackupsCheckoutSheet(
messageBackupsType = state.availableBackupTypes.first { it.tier == state.selectedMessageBackupTier!! },
availablePaymentMethods = state.availablePaymentMethods,
sheetState = checkoutSheetState,
onDismissRequest = {
viewModel.goToPreviousScreen()
},
onPaymentMethodSelected = {
viewModel.onPaymentMethodUpdated(it)
viewModel.goToNextScreen()
}
)
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
ConfirmBackupCancellationDialog(
onConfirmAndDownloadNow = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onConfirmAndDownloadLater = {
// TODO [message-backups] Set appropriate state to handle post-cancellation action.
viewModel.goToNextScreen()
},
onKeepSubscriptionClick = viewModel::goToPreviousScreen
)
}
}
}
LaunchedEffect(state.screen) {
val route = navController.currentDestination?.route ?: return@LaunchedEffect
if (route == state.screen.name) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.COMPLETED) {
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CREATING_IN_APP_PAYMENT) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_PAYMENT) {
checkoutDelegate.handleGatewaySelectionResponse(state.inAppPayment!!)
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.PROCESS_CANCELLATION) {
cancelSubscription()
viewModel.goToPreviousScreen()
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CHECKOUT_SHEET) {
return@LaunchedEffect
}
if (state.screen == MessageBackupsScreen.CANCELLATION_DIALOG) {
return@LaunchedEffect
}
val routeScreen = MessageBackupsScreen.valueOf(route)
if (routeScreen.isAfter(state.screen)) {
navController.popBackStack()
} else {
navController.navigate(state.screen.name)
}
}
}
private fun cancelSubscription() {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.CANCEL_SUBSCRIPTION,
null,
InAppPaymentType.RECURRING_BACKUP
)
)
}
override fun navigateToStripePaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToPayPalPaymentInProgress(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToPaypalPaymentInProgressFragment(
InAppPaymentProcessorAction.PROCESS_NEW_IN_APP_PAYMENT,
inAppPayment,
inAppPayment.type
)
)
}
override fun navigateToCreditCardForm(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(inAppPayment)
)
}
override fun navigateToIdealDetailsFragment(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(inAppPayment)
)
}
override fun navigateToBankTransferMandate(inAppPayment: InAppPaymentTable.InAppPayment) {
findNavController().safeNavigate(
MessageBackupsFlowFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(inAppPayment)
)
}
override fun onPaymentComplete(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? probably some kind of success thing?
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
override fun onSubscriptionCancelled(inAppPaymentType: InAppPaymentType) {
if (!findNavController().popBackStack()) {
requireActivity().finishAfterTransition()
}
}
override fun onProcessorActionProcessed() = Unit
override fun onUserLaunchedAnExternalApplication() {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
override fun navigateToDonationPending(inAppPayment: InAppPaymentTable.InAppPayment) {
// TODO [message-backups] What do? Are we even supporting bank transfers?
}
}

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