Compare commits

..

220 Commits

Author SHA1 Message Date
Cody Henthorne
71f947484e Bump version to 6.42.0 2023-12-06 17:11:11 -05:00
Cody Henthorne
5160164111 Updated baseline profile. 2023-12-06 16:54:43 -05:00
Cody Henthorne
7501e029ab Update translations and other static files. 2023-12-06 16:49:31 -05:00
Cody Henthorne
a678555d8d Receive calling reactions support and control ux refactor.
Co-authored-by: Nicholas <nicholas@signal.org>
2023-12-06 16:42:04 -05:00
Clark
7ce2991b0f Do not turn screen on automatically for calls. 2023-12-06 08:37:33 -05:00
Greyson Parrelli
befa396e82 Export backupV2 using actual desired file format. 2023-12-04 16:18:56 -05:00
Clark Chen
fb69fc5af2 Add backupV2 support for simple update messages. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
b540b5813e Setup backupV2 infrastructure and testing.
Co-authored-by: Clark Chen <clark@signal.org>
2023-12-04 16:18:56 -05:00
Greyson Parrelli
feb74d90f6 Update libsignal-client to 0.35.0 2023-12-04 16:18:56 -05:00
Greyson Parrelli
a0de2577e8 Add extra data to the provisioning proto. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
dbc5112ada Move send requirement calculations to a background thread. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
9f8335810c Do not resort the chat list based on identity verification updates. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
c54e2388ce Fix potential stack overflow during thread deletion. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
a8a7019411 Fix marking crashes as prompted. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
098da3c3dd Attempt to address a search crash. 2023-12-04 16:18:56 -05:00
Cody Henthorne
71b5645801 Do not show donate megaphone if currently awaiting a donation to clear. 2023-12-04 16:18:56 -05:00
Cody Henthorne
f5d9fbe91c Allow deeplinks back into Signal from iDEAL banking apps. 2023-12-04 16:18:56 -05:00
Clark
420e15c179 Fix infinite identity key storage service clash. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
74619f6f8d Prevent nested SQL error handlers. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
1355a4a28d Fix bug where username may be put in e164 column. 2023-12-04 16:18:56 -05:00
Greyson Parrelli
97c34b889a Update logging format. 2023-12-04 16:18:53 -05:00
Cody Henthorne
0b0c54d874 Perform client side checks on name and email for donation flows. 2023-12-04 16:18:53 -05:00
Jim Gustafson
1005be006f Update to RingRTC v2.34.5 2023-12-04 16:18:53 -05:00
Greyson Parrelli
8db113a19b Fix potential crash in username share sheet. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
075df8a26d Fix crash if you search for a malformed username. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
38cf3f40e1 Fix various places where we should show the username. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
4a0abbbee7 Ensure ACI/PNI are associated after processing a PNI promotion message. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
15f1201a76 Remove leftover deprecated gv1 code. 2023-12-04 16:18:53 -05:00
Nicholas Tinsley
b152723ed2 Restrict StreamingTranscoder usage to feature flag. 2023-12-04 16:18:53 -05:00
Clark
84a2832a65 Fix getAndPossiblyMerge to run after successful transaction in case of nested transactions. 2023-12-04 16:18:53 -05:00
Clark
8037494f7a Stop throwing an assertion error when getting attachment TransformationProperties. 2023-12-04 16:18:53 -05:00
Nicholas Tinsley
97c1ace020 Do not display stop icon on uncancelable progress. 2023-12-04 16:18:53 -05:00
Nicholas
64457b0235 Unique string resource for "edited now". 2023-12-04 16:18:53 -05:00
Nicholas
67ef831681 Only generate incremental mac for faststart videos. 2023-12-04 16:18:53 -05:00
Nicholas Tinsley
1fd6aae3d9 Make "Retry" text clickable when downloading attachment. 2023-12-04 16:18:53 -05:00
Clark
61810cc977 Re-use session objects during multi-recipient encryption. 2023-12-04 16:18:53 -05:00
Nicholas Tinsley
59401e18ed Prevent crash on audio focus permission denied.
Addresses #13283.
2023-12-04 16:18:53 -05:00
Nicholas Tinsley
30eff93fa1 Fix donation FAQ URL. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
7c5bae3b53 Remove unnecessary jcenter repository. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
ee16e4236e Convert the topmost build.gradle to .gradlew.kts. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
30e9cf9dc8 Convert settings and dependencies to .gradle.kts. 2023-12-04 16:18:53 -05:00
Greyson Parrelli
ac5d0bf8a3 Convert main app build.gradle to .gradle.kts. 2023-12-04 16:18:45 -05:00
Greyson Parrelli
923eb05e59 Converted libsignal-service to .gradle.kts. 2023-12-04 16:18:11 -05:00
Greyson Parrelli
8f59e51445 Move test into proper directory. 2023-12-04 16:18:11 -05:00
Greyson Parrelli
766733617e Converted all minor modules to .gradle.kts. 2023-12-04 16:18:11 -05:00
Nicholas Tinsley
d77744c562 Additional logging around retry button. 2023-12-04 16:18:11 -05:00
Nicholas Tinsley
0d6db1305e Don't check recorded voice note size if discarding. 2023-12-04 16:18:11 -05:00
Nicholas
61c2e59f41 Only update profiles if their contents has changed. 2023-12-04 16:18:11 -05:00
Clark
47dd7adf4b Use libsignal to derive access key during group send. 2023-12-04 16:18:11 -05:00
Nicholas
016736c455 Encrypting for multiple senders benchmark. 2023-12-04 16:18:11 -05:00
Cody Henthorne
6d3924ba43 Add group call NOT_ACCEPTED sync handling. 2023-12-04 16:18:10 -05:00
Greyson Parrelli
428f963243 Remove unique constraint from dlist table. 2023-12-04 16:18:10 -05:00
Mridul Barman
dd871b64ea Remove duplicate permission filtering.
Closes #12987
2023-12-04 16:18:10 -05:00
Greyson Parrelli
38863f618a Fix back navigation in username link settings screen. 2023-12-04 16:18:10 -05:00
Greyson Parrelli
8023285b9d Only mark username corrupted after repeated failures. 2023-12-04 16:18:10 -05:00
Greyson Parrelli
1aa7175006 Update order for attachment menu options. 2023-12-04 16:18:10 -05:00
Cody Henthorne
1222c30738 Bump version to 6.41.3 2023-12-04 16:12:20 -05:00
Cody Henthorne
0c8e62add9 Update translations and other static files. 2023-12-04 16:07:13 -05:00
Cody Henthorne
eb1d06b4a6 Fix thumbnail info generation bug in notifications. 2023-12-04 16:01:43 -05:00
Greyson Parrelli
d58c3292d7 Only use apk uploadTimestamp for non-website builds.
Relates to #13273
2023-12-04 15:54:14 -05:00
Greyson Parrelli
4320d26a3d Do not read PNP FF in job. 2023-12-04 15:12:20 -05:00
Cody Henthorne
3ca4e33d94 Fix sepa badge redemption job. 2023-12-04 15:12:20 -05:00
Greyson Parrelli
19e726a630 Bump version to 6.41.2 2023-11-17 15:10:15 -05:00
Greyson Parrelli
96dddef271 Update translations and other static files. 2023-11-17 15:09:35 -05:00
Cody Henthorne
34a228f85e Remove GV1 migration support. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
213d996168 Fix issues with some japanese numbers being detected as shortcodes. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
5a159ce01f Update libphonenumber to 8.13.23 2023-11-17 14:25:47 -05:00
Cody Henthorne
fed9c64113 Fix false-positive CVC errors in credit card donation flow. 2023-11-17 14:25:47 -05:00
Nicholas Tinsley
2d835581a5 Set audio picker bottom sheet text color to onSurface. 2023-11-17 14:25:47 -05:00
Nicholas Tinsley
c8f1ebdf4c Fix speakerphone drawables for selection. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
98e3530acd Bump version to 6.41.1 2023-11-16 17:12:19 -05:00
Greyson Parrelli
1a5b216dd5 Update translations and other static files. 2023-11-16 17:11:47 -05:00
Cody Henthorne
ae98d5e3bd Fix NPE in wifi direct connection establishment. 2023-11-16 16:37:38 -05:00
Greyson Parrelli
750825b3c3 Fix potential bug with the in-app updater. 2023-11-16 16:19:50 -05:00
Cody Henthorne
8c255256c9 Remove mms_config xmls. 2023-11-16 16:19:50 -05:00
Cody Henthorne
19626361ec Fix bug allowing creation of new and sending in existing MMS groups. 2023-11-16 16:19:50 -05:00
Cody Henthorne
df4bd1fa4a Replace monthly badge expires with cancellation dialogs. 2023-11-16 10:22:01 -05:00
Greyson Parrelli
62bf5abd8d Bump version to 6.41.0 2023-11-15 17:32:01 -05:00
Greyson Parrelli
cd9ec9f346 Update translations and other static files. 2023-11-15 17:30:04 -05:00
Greyson Parrelli
cf7d5b3481 Remove deprecated storage service fields. 2023-11-15 17:02:44 -05:00
Cody Henthorne
12f9ac3aa4 Use shorter string for tab for better localization. 2023-11-15 17:02:44 -05:00
Greyson Parrelli
4519cdb49c Remove some unnecessary transactions in MessageContentProcessor. 2023-11-15 17:02:28 -05:00
Jim Gustafson
d20b6f355c Enable opus low bitrate redundancy for internal testing. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
70e64003f9 Unconditionally enable the PNI capability. 2023-11-15 17:02:21 -05:00
Nicholas Tinsley
0a4644e743 Update conversation shortcuts onPause. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
c428d23d8b Install prompt notification should dismiss failures and vice-versa. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
d6b189badc Fix potential binding crash. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
6e899391c0 Add back the foreign key transaction dance. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e0acbcc32d Perform one database upgrade at a time, saving progress as we go. 2023-11-15 17:02:21 -05:00
Cody Henthorne
95fb9ea117 Remove old remote configs. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e80b7cf0a2 Store receipt fields as booleans instead of counts. 2023-11-15 17:02:21 -05:00
Cody Henthorne
5e70c06075 Rotate ideal and sepa flags. 2023-11-15 17:02:21 -05:00
Cody Henthorne
1413b74f76 Add 'Add remote donate megaphone' to internal settings. 2023-11-15 17:02:21 -05:00
Cody Henthorne
bf0548e802 Fix donation-based remote config region checks. 2023-11-15 17:02:21 -05:00
Clark
b7e1863526 Fix timezone weirdness with scheduled messages. 2023-11-15 17:02:21 -05:00
Cody Henthorne
f189188563 Fix snackbar colors on older api verisons. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
2f52664820 Merge MediaMmsMessageRecord into MmsMessageRecord. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
5f6fa73be9 Delete NotificationMmsMessageRecord. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
b7ec913cb9 Improve receipt perf by caching the pending PNI signature table. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
ebef4b079c Fix LRUCache to be ordered by access time. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
a81e5c4e6b Improve receipt processing via faster thread updates. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
b0733dcd51 Reduce transactions during getAndPossiblyMerge. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e9bd35619d Add migration to fix registration state of some users. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
6528b34152 Fix username education layout when text is long. 2023-11-11 13:34:48 -05:00
Rashad Sookram
b60c02e0c7 Update to RingRTC v2.34.4 2023-11-11 13:34:48 -05:00
Greyson Parrelli
a0792d166b Add additional logging around apk updates. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
fcf36c4bc0 Fix color of x in color picker. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
e5b617cd16 Fix text color in username link sharing bottom sheet. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
0acefb4521 Fix storage sync issues with usernames. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
111c8367a9 Fix discoverability setting persistence during registration. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
ead8f209b6 Fix 'next' button alignment during registration. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
96333b616b Add username link share sheet. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
5698e0deda Bump version to 6.40.4 2023-11-11 12:38:36 -05:00
Greyson Parrelli
df2ddebf6c Bump version to 6.40.3 2023-11-11 12:05:20 -05:00
Greyson Parrelli
71ab7528e7 Fix shared group membership check. 2023-11-11 12:04:55 -05:00
Cody Henthorne
b4e459d831 Bump version to 6.40.2 2023-11-10 15:44:59 -05:00
Cody Henthorne
a57d3fdf3f Updated baseline profile. 2023-11-10 15:39:20 -05:00
Cody Henthorne
2c207873be Update translations and other static files. 2023-11-10 15:34:43 -05:00
Cody Henthorne
fc8385113f Fix system ANR when loading avatars for system UI. 2023-11-10 15:27:57 -05:00
Cody Henthorne
95d7d26f11 Add SEPA max amount exceeded dialog. 2023-11-10 15:27:57 -05:00
AsamK
43a13964bd Fix leaking okhttp response in error case.
Closes #13246
2023-11-10 15:27:57 -05:00
Cody Henthorne
d2053d2db7 Bump version to 6.40.1 2023-11-09 16:52:42 -05:00
Cody Henthorne
8ba2bcaa53 Updated baseline profile. 2023-11-09 16:30:25 -05:00
Cody Henthorne
f7abdbe97f Update translations and other static files. 2023-11-09 16:27:36 -05:00
Greyson Parrelli
91af3e60ba Fix potential NPE when building an account record. 2023-11-09 16:13:46 -05:00
Clark
8fe196cd7a Don't renotify every single message on new message. 2023-11-09 12:29:59 -05:00
Cody Henthorne
66d7241c03 Update donation learn more urls in error states. 2023-11-09 12:06:27 -05:00
Cody Henthorne
89d7c0b0d0 Bump version to 6.40.0 2023-11-08 20:11:41 -05:00
Cody Henthorne
d2ec62d681 Updated baseline profile. 2023-11-08 20:02:59 -05:00
Cody Henthorne
b6d38fe8f1 Update translations and other static files. 2023-11-08 19:57:56 -05:00
Cody Henthorne
1edc256148 Rotate ideal and sepa flags. 2023-11-08 19:51:46 -05:00
Cody Henthorne
24ac385898 Fix dark theme issues with compose bottom sheets and donation bank name typo. 2023-11-08 19:51:46 -05:00
Cody Henthorne
f062e58f7b Flesh out monthly iDEAL donation flow. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
96aec401b9 Fix username link settings navigation. 2023-11-08 19:51:46 -05:00
Nicholas Tinsley
7ff0b7aa3c Increase clickable area of media download button. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
e5ab5241d5 Centralize username logic in UsernameRepository. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
0f4f87067e Add some detailed username docs to UsernameRepository. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
3f32f816b0 Convert the UsernameRepository to an object. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
73de2dfda7 Fix opening username links. 2023-11-08 19:51:46 -05:00
Nicholas
d6fd6cb5a3 Optimize thread ID DB query. 2023-11-08 19:51:46 -05:00
Nicholas
39fbbe896f Batch insert group receipts. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
29c70acf4e Leave attachment insert early if there are no attachments. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
5cd2568776 Fix foreground service crash with state tracking. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
60a6535a12 Add internal test buttons to corrupt username state. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
f48b389449 Fix padding in edit profile screen. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
316dd210a0 Minor improvements to username tooltip. 2023-11-07 22:11:08 -05:00
Greyson Parrelli
a60712c09d If both usernames hashes are empty, consider valid. 2023-11-07 14:44:46 -05:00
Nicholas Tinsley
482cd564ff Lower priority of ConversationShortcutUpdateJob. 2023-11-07 13:37:21 -05:00
Greyson Parrelli
ac1171d43b Allow install of nightlies with the same version code but newer upload dates. 2023-11-07 12:51:09 -05:00
Greyson Parrelli
ed8953c430 Fix logging around username link reset failures. 2023-11-07 12:11:22 -05:00
Cody Henthorne
9a8aecaf3f Improve donation strings localization. 2023-11-07 11:56:01 -05:00
Greyson Parrelli
423719e7bc Fix username QR code sharing. 2023-11-07 11:43:40 -05:00
Cody Henthorne
7f2b6a874e Flesh out iDEAL sad path UX and address UI polish feedback. 2023-11-07 11:04:36 -05:00
Greyson Parrelli
cfe5ea3f9b Add the ability to download the current perfetto trace in Spinner. 2023-11-07 09:07:59 -05:00
Greyson Parrelli
07aa058a46 Update username consistency error handling. 2023-11-06 14:49:51 -05:00
Nicholas Tinsley
6cadf93c43 Forward touch events in timestamp of text message. 2023-11-06 14:48:35 -05:00
Cody Henthorne
60eb1332d2 Fix lifespan typo for ExternalLaunchDonationJob. 2023-11-06 11:04:24 -05:00
Nicholas Tinsley
a9ee7e93fd Increase IdentityKey cache size. 2023-11-06 10:46:53 -05:00
Clark
2782216e52 Remove slow getResourceAsStream when loading the Conscrypt provider. 2023-11-06 09:56:11 -05:00
Nicholas Tinsley
d22537c5f2 Fix LocalMetrics for text sends. 2023-11-03 15:24:36 -04:00
Nicholas Tinsley
57aa6c19e1 Set silent group updates to low job priority. 2023-11-03 15:20:38 -04:00
Nicholas Tinsley
761553d392 Avoid unnecessary lock acquisition. 2023-11-03 15:12:29 -04:00
Greyson Parrelli
29350ab7b0 Add a QR code link and tooltip in the profile settings. 2023-11-03 14:33:07 -04:00
Cody Henthorne
528ccc1e9d Navigate to main donation screen if user leaves for external app. 2023-11-03 12:56:03 -04:00
Cody Henthorne
20d26ad7ca Expand spinner timestamp conversion to job tables. 2023-11-03 12:51:17 -04:00
Cody Henthorne
5d23c5c902 Increase sepa receipt request lifespan to cover at least 14 business days. 2023-11-03 12:49:19 -04:00
Greyson Parrelli
145794bf04 Add the ability to set job priority. 2023-11-03 12:21:27 -04:00
Greyson Parrelli
d00f2aa8d0 Convert EditProfileFragment to kotlin. 2023-11-03 10:40:13 -04:00
Greyson Parrelli
3a20375567 Update profile edit screen to remove subtitles. 2023-11-03 09:25:09 -04:00
Greyson Parrelli
7be93a8a44 Rename profile fragments so they make sense. 2023-11-03 09:14:17 -04:00
Jim Gustafson
b5e4c4e92a Update to RingRTC v2.34.3 2023-11-02 21:30:07 -04:00
Greyson Parrelli
20285796bd Fix username link sharing toolbar. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
7826ff94e3 Also check PNI prekey age on message send. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
f1dccbb64d Consider empty usernames as absent. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
528e301ce4 Improve username creation error debouncing. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
af016a9c79 Fix username error message text wrapping. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
cbd5738543 Fix some username creation tinting issues in dark theme. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
2dd0899a3d Fix nightly updates. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
e486a4baef Bump version to 6.39.1 2023-11-02 19:18:37 -04:00
Greyson Parrelli
5fc11baf9e Update translations and other static files. 2023-11-02 19:18:37 -04:00
Nicholas
157777cac1 Batch update DB upon group receipt. 2023-11-02 19:18:37 -04:00
Greyson Parrelli
99d0ee6725 Fix cursor crash in ConversationSettings.
Best way to fix a cursor crash it to... stop using cursors.

Fairly confident the crash was caused by us closing the cursor while it
was read. And there just isn't a good way to avoid that with how it was
written. So this ended up being a great excuse to move over to models.
2023-11-02 11:58:23 -04:00
Greyson Parrelli
b5c1051506 Attempt to fix AccountRecord restore crash.
My guess is that we're seeing a crash when updating because we're using
an out-of-date recipient snapshot that has an old/invalid storageId.

This commit uses a fresher recipient, and it prefers using the raw
record (what's in the DB) instead.
2023-11-02 10:25:17 -04:00
Greyson Parrelli
bba3334df5 Bump version to 6.39.0 2023-11-01 20:45:16 -04:00
Greyson Parrelli
74488feec2 Update translations and other static files. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
54953abc67 Reduce nightly update check interval to 2 hours. 2023-11-01 20:45:16 -04:00
Cody Henthorne
117bbdbcdf Show dialog when attempting to donate again while still processing previous donation. 2023-11-01 20:45:16 -04:00
Nicholas Tinsley
b96b99c1c4 Swallow touch events in forwarding sheet overlay.
Addresses #13239.
2023-11-01 20:45:16 -04:00
Cody Henthorne
6e856a7648 Update bank mandate CTA UX. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
0659edb762 Add a new foreground service for attachment progress. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
dcb870c432 Only show ACI SN's. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
772bafbe43 Inline feature flag to show ACI SN by default. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
a9be6aff44 Fix delete crash. 2023-11-01 20:45:16 -04:00
Cody Henthorne
dcd7ec7383 Treat pnp builds also as staging builds. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
c69a4dda00 Convert GenericForegroundService to kotlin. 2023-11-01 20:45:16 -04:00
Greyson Parrelli
a911926119 Always for a full contact sync via ContactDiscovery.refreshAll(). 2023-11-01 20:45:15 -04:00
Greyson Parrelli
6f30aec4f2 Improve LocalMetrics logging. 2023-11-01 20:45:15 -04:00
Greyson Parrelli
5a005fb809 Build a simple ANR detector. 2023-11-01 20:45:15 -04:00
Cody Henthorne
776a4c5dce Fix string issues. 2023-10-31 10:19:34 -04:00
Jim Gustafson
c53c316303 Update to RingRTC v2.34.2 2023-10-31 09:50:07 -04:00
Greyson Parrelli
622aa844e4 Clear glide memory cache on attachment delete. 2023-10-31 09:50:07 -04:00
Greyson Parrelli
de2cf6026e Fix nightly build. 2023-10-30 18:09:17 -04:00
Greyson Parrelli
a8e02b9ced Move envelope follow-up operations outside of the transaction. 2023-10-30 18:09:17 -04:00
Nicholas Tinsley
297308ad76 Only suggest scheduled message times in the future.
Addresses #13139
2023-10-30 18:09:17 -04:00
Greyson Parrelli
ea0c3dbe5a Add logging around database transactions and group recipient creation. 2023-10-30 18:09:17 -04:00
Greyson Parrelli
b8d229e58e Enable auto-updates for nightly builds. 2023-10-30 18:09:17 -04:00
Greyson Parrelli
c4f5110148 Stop falling back to CDN0 for attachments. 2023-10-30 18:09:17 -04:00
Jim Gustafson
7fdd7e89bd Update to RingRTC v2.34.1 2023-10-30 18:09:17 -04:00
Greyson Parrelli
2378346537 Bump version to 6.38.2 2023-10-30 17:54:17 -04:00
Greyson Parrelli
72fc5fc3b1 Update translations and other static files. 2023-10-30 17:53:56 -04:00
Greyson Parrelli
c063c99ba6 Fix contact joined messages. 2023-10-30 17:44:25 -04:00
Nicholas Tinsley
90341f0a6e Finish updating audio output assets. 2023-10-30 11:48:13 -04:00
Nicholas Tinsley
cdb9df5aba Bump version to 6.38.1 2023-10-27 19:26:48 -04:00
Nicholas Tinsley
1f6d9d6422 Updated baseline profile. 2023-10-27 19:26:28 -04:00
Nicholas Tinsley
ffbda7e521 Update translations and other static files. 2023-10-27 19:23:15 -04:00
Nicholas Tinsley
3b5ef29047 Update IncomingMessage in benchmark. 2023-10-27 18:32:35 -04:00
Nicholas Tinsley
14cf6ceb84 Change audio output assets. 2023-10-26 11:59:20 -04:00
Nicholas Tinsley
5fb940ff2a Update speaker view hint's legibility. 2023-10-26 11:29:26 -04:00
Nicholas Tinsley
f446e18289 Require attachment data to be shown in "All" list. 2023-10-26 11:23:47 -04:00
Cody Henthorne
84f26b32d6 Fix snc causing thread reordering. 2023-10-26 10:43:44 -04:00
841 changed files with 32991 additions and 22106 deletions

3
.gitignore vendored
View File

@@ -3,6 +3,7 @@ captures/
project.properties
keystore.debug.properties
keystore.staging.properties
nightly-url.txt
.project
.settings
bin/
@@ -28,4 +29,4 @@ jni/libspeex/.deps/
pkcs11.password
dev.keystore
maps.key
local/
local/

View File

@@ -1,685 +0,0 @@
import com.android.build.api.dsl.ManagedVirtualDevice
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'androidx.navigation.safeargs'
id 'org.jlleitschuh.gradle.ktlint'
id 'org.jetbrains.kotlin.android'
id 'app.cash.exhaustive'
id 'kotlin-parcelize'
id 'com.squareup.wire'
id 'translations'
id 'licenses'
}
apply from: 'static-ips.gradle'
wire {
kotlin {
javaInterop = true
}
sourcePath {
srcDir 'src/main/protowire'
}
protoPath {
srcDir "${project.rootDir}/libsignal-service/src/main/protowire"
}
}
ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1351
def canonicalVersionName = "6.38.0"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
'armeabi-v7a' : 1,
'arm64-v8a' : 2,
'x86' : 3,
'x86_64' : 4]
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
def selectableVariants = [
'nightlyProdSpinner',
'nightlyProdPerf',
'nightlyProdRelease',
'nightlyStagingRelease',
'nightlyPnpPerf',
'nightlyPnpRelease',
'playProdDebug',
'playProdSpinner',
'playProdCanary',
'playProdPerf',
'playProdBenchmark',
'playProdInstrumentation',
'playProdRelease',
'playStagingDebug',
'playStagingCanary',
'playStagingSpinner',
'playStagingPerf',
'playStagingInstrumentation',
'playPnpDebug',
'playPnpSpinner',
'playStagingRelease',
'websiteProdSpinner',
'websiteProdRelease',
]
android {
namespace 'org.thoughtcrime.securesms'
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
flavorDimensions 'distribution', 'environment'
useLibrary 'org.apache.http.legacy'
testBuildType 'instrumentation'
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = ["-Xallow-result-return-type"]
}
signingConfigs {
if (keystores.debug != null) {
debug {
storeFile file("${project.rootDir}/${keystores.debug.storeFile}")
storePassword keystores.debug.storePassword
keyAlias keystores.debug.keyAlias
keyPassword keystores.debug.keyPassword
}
}
}
testOptions {
execution 'ANDROIDX_TEST_ORCHESTRATOR'
unitTests {
includeAndroidResources = true
}
managedDevices {
devices {
pixel3api30 (ManagedVirtualDevice) {
device = "Pixel 3"
apiLevel = 30
systemImageSource = "google-atd"
require64Bit = false
}
}
}
}
sourceSets {
test {
java.srcDirs += "$projectDir/src/testShared"
}
androidTest {
java.srcDirs += "$projectDir/src/testShared"
}
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility signalJavaVersion
targetCompatibility signalJavaVersion
}
packagingOptions {
resources {
excludes += ['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']
}
}
buildFeatures {
viewBinding true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = '1.4.4'
}
defaultConfig {
versionCode canonicalVersionCode * postFixSize
versionName canonicalVersionName
minSdkVersion signalMinSdkVersion
targetSdkVersion signalTargetSdkVersion
multiDexEnabled true
vectorDrawables.useSupportLibrary = true
project.ext.set("archivesBaseName", "Signal")
manifestPlaceholders = [mapsKey:"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"]
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\""
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
buildConfigField "String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\""
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\", \"https://sfu.staging.test.voip.signal.org\"}"
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
buildConfigField "String[]", "SIGNAL_SERVICE_IPS", service_ips
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String[]", "SIGNAL_CDSI_IPS", cdsi_ips
buildConfigField "String[]", "SIGNAL_SVR2_IPS", svr2_ips
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\""
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"unset\""
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
buildConfigField "boolean", "TRACING_ENABLED", "false"
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
}
resourceConfigurations += []
splits {
abi {
enable !project.hasProperty('generateBaselineProfile')
reset()
include 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
universalApk true
}
}
testInstrumentationRunner "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
}
buildTypes {
debug {
if (keystores['debug'] != null) {
signingConfig signingConfigs.debug
}
isDefault true
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard/proguard-firebase-messaging.pro',
'proguard/proguard-google-play-services.pro',
'proguard/proguard-jackson.pro',
'proguard/proguard-sqlite.pro',
'proguard/proguard-appcompat-v7.pro',
'proguard/proguard-square-okhttp.pro',
'proguard/proguard-square-okio.pro',
'proguard/proguard-rounded-image-view.pro',
'proguard/proguard-glide.pro',
'proguard/proguard-shortcutbadger.pro',
'proguard/proguard-retrofit.pro',
'proguard/proguard-webrtc.pro',
'proguard/proguard-klinker.pro',
'proguard/proguard-mobilecoin.pro',
'proguard/proguard-retrolambda.pro',
'proguard/proguard-okhttp.pro',
'proguard/proguard-ez-vcard.pro',
'proguard/proguard.cfg'
testProguardFiles 'proguard/proguard-automation.pro',
'proguard/proguard.cfg'
manifestPlaceholders = [mapsKey:getMapsKey()]
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
}
instrumentation {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
applicationIdSuffix ".instrumentation"
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Instrumentation\""
}
spinner {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Spinner\""
}
release {
minifyEnabled true
proguardFiles = buildTypes.debug.proguardFiles
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
}
perf {
initWith debug
isDefault false
debuggable false
minifyEnabled true
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
buildConfigField "boolean", "TRACING_ENABLED", "true"
}
benchmark {
initWith debug
isDefault false
debuggable false
minifyEnabled true
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Benchmark\""
buildConfigField "boolean", "TRACING_ENABLED", "true"
}
canary {
initWith debug
isDefault false
minifyEnabled false
matchingFallbacks = ['debug']
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Canary\""
}
}
productFlavors {
play {
dimension 'distribution'
isDefault true
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
buildConfigField "String", "APK_UPDATE_URL", "null"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
}
website {
dimension 'distribution'
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
buildConfigField "String", "APK_UPDATE_URL", "\"https://updates.signal.org/android\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
}
nightly {
dimension 'distribution'
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
buildConfigField "String", "APK_UPDATE_URL", "null"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
prod {
dimension 'environment'
isDefault true
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\""
}
staging {
dimension 'environment'
applicationIdSuffix ".staging"
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\""
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\""
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
}
pnp {
dimension 'environment'
initWith staging
applicationIdSuffix ".pnp"
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\""
}
}
lint {
abortOnError true
baseline file('lint-baseline.xml')
checkReleaseBuilds false
disable 'LintError'
}
android.applicationVariants.all { variant ->
variant.outputs.each { output ->
if (output.baseName.contains('nightly')) {
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
def tag = getCurrentGitTag()
if (tag != null && tag.length() > 0) {
if (tag.startsWith("v")) {
tag = tag.substring(1)
}
output.versionNameOverride = tag
}
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
def abiName = output.getFilter("ABI") ?: 'universal'
def postFix = abiPostFix.get(abiName, 0)
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
}
android.variantFilter { variant ->
def distribution = variant.getFlavors().get(0).name
def environment = variant.getFlavors().get(1).name
def buildType = variant.buildType.name
def fullName = distribution + environment.capitalize() + buildType.capitalize()
if (!selectableVariants.contains(fullName)) {
variant.setIgnore(true)
}
}
android.buildTypes.each {
if (it.name != 'release') {
sourceSets.findByName(it.name).java.srcDirs += "$projectDir/src/debug/java"
} else {
sourceSets.findByName(it.name).java.srcDirs += "$projectDir/src/release/java"
}
}
}
dependencies {
implementation libs.androidx.fragment.ktx
lintChecks project(':lintchecks')
coreLibraryDesugaring libs.android.tools.desugar
implementation (libs.androidx.appcompat) {
version {
strictly '1.6.1'
}
}
implementation libs.androidx.window.window
implementation libs.androidx.window.java
implementation libs.androidx.recyclerview
implementation libs.material.material
implementation libs.androidx.legacy.support
implementation libs.androidx.preference
implementation libs.androidx.legacy.preference
implementation libs.androidx.gridlayout
implementation libs.androidx.exifinterface
implementation libs.androidx.compose.rxjava3
implementation libs.androidx.compose.runtime.livedata
implementation libs.androidx.constraintlayout
implementation libs.androidx.multidex
implementation libs.androidx.navigation.fragment.ktx
implementation libs.androidx.navigation.ui.ktx
implementation libs.androidx.lifecycle.viewmodel.ktx
implementation libs.androidx.lifecycle.livedata.ktx
implementation libs.androidx.lifecycle.process
implementation libs.androidx.lifecycle.viewmodel.savedstate
implementation libs.androidx.lifecycle.common.java8
implementation libs.androidx.lifecycle.reactivestreams.ktx
implementation libs.androidx.camera.core
implementation libs.androidx.camera.camera2
implementation libs.androidx.camera.lifecycle
implementation libs.androidx.camera.view
implementation libs.androidx.concurrent.futures
implementation libs.androidx.autofill
implementation libs.androidx.biometric
implementation libs.androidx.sharetarget
implementation libs.androidx.profileinstaller
implementation libs.androidx.asynclayoutinflater
implementation libs.androidx.asynclayoutinflater.appcompat
implementation (libs.firebase.messaging) {
exclude group: 'com.google.firebase', module: 'firebase-core'
exclude group: 'com.google.firebase', module: 'firebase-analytics'
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
}
implementation libs.google.play.services.maps
implementation libs.google.play.services.auth
implementation libs.bundles.media3
implementation libs.conscrypt.android
implementation libs.signal.aesgcmprovider
implementation project(':libsignal-service')
implementation project(':paging')
implementation project(':core-util')
implementation project(':glide-config')
implementation project(':video')
implementation project(':device-transfer')
implementation project(':image-editor')
implementation project(':donations')
implementation project(':contacts')
implementation project(':qr')
implementation project(':sms-exporter')
implementation project(':sticky-header-grid')
implementation project(':photoview')
implementation project(':glide-webp')
implementation libs.libsignal.android
implementation libs.mobilecoin
implementation libs.signal.ringrtc
implementation libs.leolin.shortcutbadger
implementation libs.emilsjolander.stickylistheaders
implementation libs.apache.httpclient.android
implementation libs.glide.glide
implementation libs.roundedimageview
implementation libs.materialish.progress
implementation libs.greenrobot.eventbus
implementation libs.google.zxing.android.integration
implementation libs.google.zxing.core
implementation libs.google.flexbox
implementation (libs.subsampling.scale.image.view) {
exclude group: 'com.android.support', module: 'support-annotations'
}
implementation (libs.android.tooltips) {
exclude group: 'com.android.support', module: 'appcompat-v7'
}
implementation (libs.android.smsmms) {
exclude group: 'com.squareup.okhttp', module: 'okhttp'
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
}
implementation libs.stream
implementation libs.lottie
implementation libs.signal.android.database.sqlcipher
implementation libs.androidx.sqlite
implementation (libs.google.ez.vcard) {
exclude group: 'com.fasterxml.jackson.core'
exclude group: 'org.freemarker'
}
implementation libs.dnsjava
implementation libs.kotlinx.collections.immutable
implementation libs.accompanist.permissions
spinnerImplementation project(":spinner")
canaryImplementation libs.square.leakcanary
testImplementation testLibs.junit.junit
testImplementation testLibs.assertj.core
testImplementation testLibs.mockito.core
testImplementation testLibs.mockito.kotlin
testImplementation testLibs.androidx.test.core
testImplementation (testLibs.robolectric.robolectric) {
exclude group: 'com.google.protobuf', module: 'protobuf-java'
}
testImplementation testLibs.robolectric.shadows.multidex
testImplementation (testLibs.bouncycastle.bcprov.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
testImplementation (testLibs.bouncycastle.bcpkix.jdk15on) { version { strictly "1.70" } } // Used by roboelectric
testImplementation testLibs.conscrypt.openjdk.uber // Used by robolectric
testImplementation testLibs.hamcrest.hamcrest
testImplementation testLibs.mockk
testImplementation(testFixtures(project(":libsignal-service")))
androidTestImplementation testLibs.androidx.test.ext.junit
androidTestImplementation testLibs.espresso.core
androidTestImplementation testLibs.androidx.test.core
androidTestImplementation testLibs.androidx.test.core.ktx
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
androidTestImplementation testLibs.mockito.android
androidTestImplementation testLibs.mockito.kotlin
androidTestImplementation testLibs.mockk.android
androidTestImplementation testLibs.square.okhttp.mockserver
instrumentationImplementation (libs.androidx.fragment.testing) {
exclude group: 'androidx.test', module: 'core'
}
testImplementation testLibs.espresso.core
implementation libs.kotlin.stdlib.jdk8
implementation libs.kotlin.reflect
implementation libs.jackson.module.kotlin
implementation libs.rxjava3.rxandroid
implementation libs.rxjava3.rxkotlin
implementation libs.rxdogtag
androidTestUtil testLibs.androidx.test.orchestrator
implementation project(':core-ui')
ktlintRuleset libs.ktlint.twitter.compose
}
def getLastCommitTimestamp() {
if (!(new File('.git').exists())) {
return System.currentTimeMillis().toString()
}
new ByteArrayOutputStream().withStream { os ->
exec {
executable = 'git'
args = ['log', '-1', '--pretty=format:%ct']
standardOutput = os
}
return os.toString() + "000"
}
}
def getGitHash() {
if (!(new File('.git').exists())) {
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
}
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'rev-parse', 'HEAD'
standardOutput = stdout
}
return stdout.toString().trim().substring(0, 12)
}
def getCurrentGitTag() {
if (!(new File('.git').exists())) {
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
}
def stdout = new ByteArrayOutputStream()
exec {
commandLine 'git', 'tag', '--points-at', 'HEAD'
standardOutput = stdout
}
def output = stdout.toString().trim()
if (output != null && output.size() > 0) {
def tags = output.split('\n').toList()
return tags.stream().filter(t -> t.contains('nightly')).findFirst().orElse(tags.get(0))
} else {
return null
}
}
tasks.withType(Test) {
testLogging {
events "failed"
exceptionFormat "full"
showCauses true
showExceptions true
showStackTraces true
}
}
def loadKeystoreProperties(filename) {
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
if (keystorePropertiesFile.exists()) {
def keystoreProperties = new Properties()
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
return keystoreProperties
} else {
return null
}
}
static def getDateSuffix() {
def date = new Date()
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
return formattedDate
}
def getMapsKey() {
def mapKey = file("${project.rootDir}/maps.key")
if (mapKey.exists()) {
return mapKey.readLines()[0]
}
return "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
}

739
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,739 @@
import com.android.build.api.dsl.ManagedVirtualDevice
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import java.io.ByteArrayOutputStream
import java.io.FileInputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("androidx.navigation.safeargs")
id("org.jlleitschuh.gradle.ktlint")
id("org.jetbrains.kotlin.android")
id("app.cash.exhaustive")
id("kotlin-parcelize")
id("com.squareup.wire")
id("translations")
id("licenses")
}
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1366
val canonicalVersionName = "6.42.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 keystores: Map<String, Properties?> = mapOf("debug" to loadKeystoreProperties("keystore.debug.properties"))
val selectableVariants = listOf(
"nightlyProdSpinner",
"nightlyProdPerf",
"nightlyProdRelease",
"nightlyStagingRelease",
"nightlyPnpPerf",
"nightlyPnpRelease",
"playProdDebug",
"playProdSpinner",
"playProdCanary",
"playProdPerf",
"playProdBenchmark",
"playProdInstrumentation",
"playProdRelease",
"playStagingDebug",
"playStagingCanary",
"playStagingSpinner",
"playStagingPerf",
"playStagingInstrumentation",
"playPnpDebug",
"playPnpSpinner",
"playStagingRelease",
"websiteProdSpinner",
"websiteProdRelease"
)
val signalBuildToolsVersion: String by rootProject.extra
val signalCompileSdkVersion: String by rootProject.extra
val signalTargetSdkVersion: Int by rootProject.extra
val signalMinSdkVersion: Int by rootProject.extra
val signalJavaVersion: JavaVersion by rootProject.extra
val signalKotlinJvmTarget: String by rootProject.extra
wire {
kotlin {
javaInterop = true
}
sourcePath {
srcDir("src/main/protowire")
}
protoPath {
srcDir("${project.rootDir}/libsignal-service/src/main/protowire")
}
}
ktlint {
version.set("0.49.1")
}
android {
namespace = "org.thoughtcrime.securesms"
buildToolsVersion = signalBuildToolsVersion
compileSdkVersion = signalCompileSdkVersion
flavorDimensions += listOf("distribution", "environment")
useLibrary("org.apache.http.legacy")
testBuildType = "instrumentation"
kotlinOptions {
jvmTarget = signalKotlinJvmTarget
freeCompilerArgs = listOf("-Xallow-result-return-type")
}
keystores["debug"]?.let { properties ->
signingConfigs.getByName("debug").apply {
storeFile = file("${project.rootDir}/${properties.getProperty("storeFile")}")
storePassword = properties.getProperty("storePassword")
keyAlias = properties.getProperty("keyAlias")
keyPassword = properties.getProperty("keyPassword")
}
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
unitTests {
isIncludeAndroidResources = true
}
managedDevices {
devices {
create<ManagedVirtualDevice>("pixel3api30") {
device = "Pixel 3"
apiLevel = 30
systemImageSource = "google-atd"
require64Bit = false
}
}
}
}
sourceSets {
getByName("test") {
java.srcDir("$projectDir/src/testShared")
}
getByName("androidTest") {
java.srcDir("$projectDir/src/testShared")
}
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = signalJavaVersion
targetCompatibility = signalJavaVersion
}
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")
}
}
buildFeatures {
viewBinding = true
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.4"
}
defaultConfig {
versionCode = canonicalVersionCode * postFixSize
versionName = canonicalVersionName
minSdkVersion(signalMinSdkVersion)
targetSdkVersion(signalTargetSdkVersion)
multiDexEnabled = true
vectorDrawables.useSupportLibrary = true
project.ext.set("archivesBaseName", "Signal")
manifestPlaceholders["mapsKey"] = "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
buildConfigField("long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L")
buildConfigField("String", "GIT_HASH", "\"${getGitHash()}\"")
buildConfigField("String", "SIGNAL_URL", "\"https://chat.signal.org\"")
buildConfigField("String", "STORAGE_URL", "\"https://storage.signal.org\"")
buildConfigField("String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\"")
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\"")
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\"")
buildConfigField("String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\"")
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.signal.org\"")
buildConfigField("String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\"")
buildConfigField("String", "SIGNAL_STAGING_SFU_URL", "\"https://sfu.staging.voip.signal.org\"")
buildConfigField("String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}")
buildConfigField("String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\", \"https://sfu.staging.test.voip.signal.org\"}")
buildConfigField("String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\"")
buildConfigField("int", "CONTENT_PROXY_PORT", "443")
buildConfigField("String[]", "SIGNAL_SERVICE_IPS", rootProject.extra["service_ips"] as String)
buildConfigField("String[]", "SIGNAL_STORAGE_IPS", rootProject.extra["storage_ips"] as String)
buildConfigField("String[]", "SIGNAL_CDN_IPS", rootProject.extra["cdn_ips"] as String)
buildConfigField("String[]", "SIGNAL_CDN2_IPS", rootProject.extra["cdn2_ips"] as String)
buildConfigField("String[]", "SIGNAL_CDN3_IPS", rootProject.extra["cdn3_ips"] as String)
buildConfigField("String[]", "SIGNAL_SFU_IPS", rootProject.extra["sfu_ips"] as String)
buildConfigField("String[]", "SIGNAL_CONTENT_PROXY_IPS", rootProject.extra["content_proxy_ips"] as String)
buildConfigField("String[]", "SIGNAL_CDSI_IPS", rootProject.extra["cdsi_ips"] as String)
buildConfigField("String[]", "SIGNAL_SVR2_IPS", rootProject.extra["svr2_ips"] as String)
buildConfigField("String", "SIGNAL_AGENT", "\"OWA\"")
buildConfigField("String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\"")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\"")
buildConfigField("String[]", "LANGUAGES", "new String[]{ ${languageList().map { "\"$it\"" }.joinToString(separator = ", ")} }")
buildConfigField("int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode")
buildConfigField("String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\"")
buildConfigField("String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\"")
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"unset\"")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"unset\"")
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"unset\"")
buildConfigField("String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\"")
buildConfigField("boolean", "TRACING_ENABLED", "false")
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
resourceConfigurations += listOf()
splits {
abi {
isEnable = !project.hasProperty("generateBaselineProfile")
reset()
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
isUniversalApk = true
}
}
testInstrumentationRunner = "org.thoughtcrime.securesms.testing.SignalTestRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
buildTypes {
getByName("debug") {
if (keystores["debug"] != null) {
signingConfig = signingConfigs["debug"]
}
isDefault = true
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android.txt"),
"proguard/proguard-firebase-messaging.pro",
"proguard/proguard-google-play-services.pro",
"proguard/proguard-jackson.pro",
"proguard/proguard-sqlite.pro",
"proguard/proguard-appcompat-v7.pro",
"proguard/proguard-square-okhttp.pro",
"proguard/proguard-square-okio.pro",
"proguard/proguard-rounded-image-view.pro",
"proguard/proguard-glide.pro",
"proguard/proguard-shortcutbadger.pro",
"proguard/proguard-retrofit.pro",
"proguard/proguard-webrtc.pro",
"proguard/proguard-klinker.pro",
"proguard/proguard-mobilecoin.pro",
"proguard/proguard-retrolambda.pro",
"proguard/proguard-okhttp.pro",
"proguard/proguard-ez-vcard.pro",
"proguard/proguard.cfg"
)
testProguardFiles(
"proguard/proguard-automation.pro",
"proguard/proguard.cfg"
)
manifestPlaceholders["mapsKey"] = getMapsKey()
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Debug\"")
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(*buildTypes["debug"].proguardFiles.toTypedArray())
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Release\"")
}
create("instrumentation") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
applicationIdSuffix = ".instrumentation"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Instrumentation\"")
}
create("spinner") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Spinner\"")
}
create("perf") {
initWith(getByName("debug"))
isDefault = false
isDebuggable = false
isMinifyEnabled = true
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Perf\"")
buildConfigField("boolean", "TRACING_ENABLED", "true")
}
create("benchmark") {
initWith(getByName("debug"))
isDefault = false
isDebuggable = false
isMinifyEnabled = true
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Benchmark\"")
buildConfigField("boolean", "TRACING_ENABLED", "true")
}
create("canary") {
initWith(getByName("debug"))
isDefault = false
isMinifyEnabled = false
matchingFallbacks += "debug"
buildConfigField("String", "BUILD_VARIANT_TYPE", "\"Canary\"")
}
}
productFlavors {
create("play") {
dimension = "distribution"
isDefault = true
buildConfigField("boolean", "MANAGES_APP_UPDATES", "false")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "null")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"play\"")
}
create("website") {
dimension = "distribution"
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"website\"")
}
create("nightly") {
val apkUpdateManifestUrl = if (file("${project.rootDir}/nightly-url.txt").exists()) {
file("${project.rootDir}/nightly-url.txt").readText().trim()
} else {
"<unset>"
}
dimension = "distribution"
versionNameSuffix = "-nightly-untagged-${getDateSuffix()}"
buildConfigField("boolean", "MANAGES_APP_UPDATES", "true")
buildConfigField("String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\"")
buildConfigField("String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\"")
}
create("prod") {
dimension = "environment"
isDefault = true
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\"")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\"")
}
create("staging") {
dimension = "environment"
applicationIdSuffix = ".staging"
buildConfigField("String", "SIGNAL_URL", "\"https://chat.staging.signal.org\"")
buildConfigField("String", "STORAGE_URL", "\"https://storage-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDN3_URL", "\"https://cdn3-staging.signal.org\"")
buildConfigField("String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\"")
buildConfigField("String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\"")
buildConfigField("String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\"")
buildConfigField("String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\"")
buildConfigField("String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\"")
buildConfigField("String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\"")
buildConfigField("String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\"")
buildConfigField("String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\"")
buildConfigField("String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\"")
buildConfigField("String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\"")
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\"")
buildConfigField("String", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\"")
}
create("pnp") {
dimension = "environment"
initWith(getByName("staging"))
applicationIdSuffix = ".pnp"
buildConfigField("String", "BUILD_ENVIRONMENT_TYPE", "\"Pnp\"")
}
}
lint {
abortOnError = true
baseline = file("lint-baseline.xml")
checkReleaseBuilds = false
disable += "LintError"
}
applicationVariants.all {
val variant = this
variant.outputs
.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")) {
tag = tag.substring(1)
}
output.versionNameOverride = tag
output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk")
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
}
} else {
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
val abiName: String = output.getFilter("ABI") ?: "universal"
val postFix: Int = abiPostFix[abiName]!!
if (postFix >= postFixSize) {
throw AssertionError("postFix is too large")
}
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
}
}
}
android.variantFilter {
val distribution: String = flavors[0].name
val environment: String = flavors[1].name
val buildType: String = buildType.name
val fullName: String = distribution + environment.capitalize() + buildType.capitalize()
if (!selectableVariants.contains(fullName)) {
ignore = true
}
}
android.buildTypes.forEach {
val path: String = if (it.name == "release") {
"$projectDir/src/release/java"
} else {
"$projectDir/src/debug/java"
}
sourceSets.findByName(it.name)!!.java.srcDir(path)
}
}
dependencies {
lintChecks(project(":lintchecks"))
ktlintRuleset(libs.ktlint.twitter.compose)
coreLibraryDesugaring(libs.android.tools.desugar)
implementation(project(":libsignal-service"))
implementation(project(":paging"))
implementation(project(":core-util"))
implementation(project(":glide-config"))
implementation(project(":video"))
implementation(project(":device-transfer"))
implementation(project(":image-editor"))
implementation(project(":donations"))
implementation(project(":contacts"))
implementation(project(":qr"))
implementation(project(":sms-exporter"))
implementation(project(":sticky-header-grid"))
implementation(project(":photoview"))
implementation(project(":glide-webp"))
implementation(project(":core-ui"))
implementation(libs.androidx.fragment.ktx)
implementation(libs.androidx.appcompat) {
version {
strictly("1.6.1")
}
}
implementation(libs.androidx.window.window)
implementation(libs.androidx.window.java)
implementation(libs.androidx.recyclerview)
implementation(libs.material.material)
implementation(libs.androidx.legacy.support)
implementation(libs.androidx.preference)
implementation(libs.androidx.legacy.preference)
implementation(libs.androidx.gridlayout)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.compose.rxjava3)
implementation(libs.androidx.compose.runtime.livedata)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.multidex)
implementation(libs.androidx.navigation.fragment.ktx)
implementation(libs.androidx.navigation.ui.ktx)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.livedata.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.viewmodel.savedstate)
implementation(libs.androidx.lifecycle.common.java8)
implementation(libs.androidx.lifecycle.reactivestreams.ktx)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.androidx.concurrent.futures)
implementation(libs.androidx.autofill)
implementation(libs.androidx.biometric)
implementation(libs.androidx.sharetarget)
implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.asynclayoutinflater)
implementation(libs.androidx.asynclayoutinflater.appcompat)
implementation(libs.firebase.messaging) {
exclude(group = "com.google.firebase", module = "firebase-core")
exclude(group = "com.google.firebase", module = "firebase-analytics")
exclude(group = "com.google.firebase", module = "firebase-measurement-connector")
}
implementation(libs.google.play.services.maps)
implementation(libs.google.play.services.auth)
implementation(libs.bundles.media3)
implementation(libs.conscrypt.android)
implementation(libs.signal.aesgcmprovider)
implementation(libs.libsignal.android)
implementation(libs.mobilecoin)
implementation(libs.signal.ringrtc)
implementation(libs.leolin.shortcutbadger)
implementation(libs.emilsjolander.stickylistheaders)
implementation(libs.apache.httpclient.android)
implementation(libs.glide.glide)
implementation(libs.roundedimageview)
implementation(libs.materialish.progress)
implementation(libs.greenrobot.eventbus)
implementation(libs.google.zxing.android.integration)
implementation(libs.google.zxing.core)
implementation(libs.google.flexbox)
implementation(libs.subsampling.scale.image.view) {
exclude(group = "com.android.support", module = "support-annotations")
}
implementation(libs.android.tooltips) {
exclude(group = "com.android.support", module = "appcompat-v7")
}
implementation(libs.android.smsmms) {
exclude(group = "com.squareup.okhttp", module = "okhttp")
exclude(group = "com.squareup.okhttp", module = "okhttp-urlconnection")
}
implementation(libs.stream)
implementation(libs.lottie)
implementation(libs.signal.android.database.sqlcipher)
implementation(libs.androidx.sqlite)
implementation(libs.google.ez.vcard) {
exclude(group = "com.fasterxml.jackson.core")
exclude(group = "org.freemarker")
}
implementation(libs.dnsjava)
implementation(libs.kotlinx.collections.immutable)
implementation(libs.accompanist.permissions)
implementation(libs.kotlin.stdlib.jdk8)
implementation(libs.kotlin.reflect)
implementation(libs.jackson.module.kotlin)
implementation(libs.rxjava3.rxandroid)
implementation(libs.rxjava3.rxkotlin)
implementation(libs.rxdogtag)
"spinnerImplementation"(project(":spinner"))
"canaryImplementation"(libs.square.leakcanary)
"instrumentationImplementation"(libs.androidx.fragment.testing) {
exclude(group = "androidx.test", module = "core")
}
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.assertj.core)
testImplementation(testLibs.mockito.core)
testImplementation(testLibs.mockito.kotlin)
testImplementation(testLibs.androidx.test.core)
testImplementation(testLibs.robolectric.robolectric) {
exclude(group = "com.google.protobuf", module = "protobuf-java")
}
testImplementation(testLibs.robolectric.shadows.multidex)
testImplementation(testLibs.bouncycastle.bcprov.jdk15on) {
version {
strictly("1.70")
}
}
testImplementation(testLibs.bouncycastle.bcpkix.jdk15on) {
version {
strictly("1.70")
}
}
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.hamcrest.hamcrest)
testImplementation(testLibs.mockk)
testImplementation(testFixtures(project(":libsignal-service")))
testImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(testLibs.espresso.core)
androidTestImplementation(testLibs.androidx.test.core)
androidTestImplementation(testLibs.androidx.test.core.ktx)
androidTestImplementation(testLibs.androidx.test.ext.junit.ktx)
androidTestImplementation(testLibs.mockito.android)
androidTestImplementation(testLibs.mockito.kotlin)
androidTestImplementation(testLibs.mockk.android)
androidTestImplementation(testLibs.square.okhttp.mockserver)
androidTestUtil(testLibs.androidx.test.orchestrator)
}
fun assertIsGitRepo() {
if (!file("${project.rootDir}/.git").exists()) {
throw IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
}
}
fun getLastCommitTimestamp(): String {
assertIsGitRepo()
ByteArrayOutputStream().use { os ->
exec {
executable = "git"
args = listOf("log", "-1", "--pretty=format:%ct")
standardOutput = os
}
return os.toString() + "000"
}
}
fun getGitHash(): String {
assertIsGitRepo()
val stdout = ByteArrayOutputStream()
exec {
commandLine = listOf("git", "rev-parse", "HEAD")
standardOutput = stdout
}
return stdout.toString().trim().substring(0, 12)
}
fun getCurrentGitTag(): String? {
assertIsGitRepo()
val stdout = ByteArrayOutputStream()
exec {
commandLine = listOf("git", "tag", "--points-at", "HEAD")
standardOutput = stdout
}
val output: String = stdout.toString().trim()
return if (output.isNotEmpty()) {
val tags = output.split("\n").toList()
tags.firstOrNull { it.contains("nightly") } ?: tags[0]
} else {
null
}
}
tasks.withType<Test>().configureEach {
testLogging {
events("failed")
exceptionFormat = TestExceptionFormat.FULL
showCauses = true
showExceptions = true
showStackTraces = true
}
}
project.tasks.configureEach {
if (name.lowercase().contains("nightly") && name != "checkNightlyParams") {
dependsOn(tasks.getByName("checkNightlyParams"))
}
}
tasks.register("checkNightlyParams") {
doFirst {
if (project.gradle.startParameter.taskNames.any { it.lowercase().contains("nightly") }) {
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
throw GradleException("Cannot find 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
}
}
}
}
fun loadKeystoreProperties(filename: String): Properties? {
val keystorePropertiesFile = file("${project.rootDir}/$filename")
return if (keystorePropertiesFile.exists()) {
val keystoreProperties = Properties()
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
keystoreProperties
} else {
null
}
}
fun getDateSuffix(): String {
return SimpleDateFormat("yyyy-MM-dd-HH:mm").format(Date())
}
fun getMapsKey(): String {
val mapKey = file("${project.rootDir}/maps.key")
return if (mapKey.exists()) {
mapKey.readLines()[0]
} else {
"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
}
}
fun Project.languageList(): List<String> {
return fileTree("src/main/res") { include("**/strings.xml") }
.map { stringFile -> stringFile.parentFile.name }
.map { valuesFolderName -> valuesFolderName.replace("values-", "") }
.filter { valuesFolderName -> valuesFolderName != "values" }
.map { languageCode -> languageCode.replace("-r", "_") }
.distinct() + "en"
}
fun String.capitalize(): String {
return this.replaceFirstChar { it.uppercase() }
}

View File

@@ -0,0 +1,589 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import android.content.ContentValues
import android.database.Cursor
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.junit.Before
import org.junit.Test
import org.signal.core.util.Hex
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.readToList
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBlob
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.database.EmojiSearchTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.testing.assertIs
import org.thoughtcrime.securesms.util.TextSecurePreferences
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.UUID
import kotlin.random.Random
typealias DatabaseData = Map<String, List<Map<String, Any?>>>
class BackupTest {
companion object {
val SELF_ACI = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
val SELF_PNI = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
const val SELF_E164 = "+10000000000"
val SELF_PROFILE_KEY = ProfileKey(Random.nextBytes(32))
val ALICE_ACI = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
val ALICE_PNI = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
val ALICE_E164 = "+12222222222"
/** Columns that we don't need to check equality of */
private val IGNORED_COLUMNS: Map<String, Set<String>> = mapOf(
RecipientTable.TABLE_NAME to setOf(RecipientTable.STORAGE_SERVICE_ID)
)
/** Tables we don't need to check equality of */
private val IGNORED_TABLES: Set<String> = setOf(
EmojiSearchTable.TABLE_NAME,
"sqlite_sequence",
"message_fts_data",
"message_fts_idx",
"message_fts_docsize"
)
}
@Before
fun setup() {
SignalStore.account().setE164(SELF_E164)
SignalStore.account().setAci(SELF_ACI)
SignalStore.account().setPni(SELF_PNI)
SignalStore.account().generateAciIdentityKeyIfNecessary()
SignalStore.account().generatePniIdentityKeyIfNecessary()
}
@Test
fun emptyDatabase() {
backupTest { }
}
@Test
fun noteToSelf() {
backupTest {
individualChat(aci = SELF_ACI, givenName = "Note to Self") {
standardMessage(outgoing = true, body = "A")
standardMessage(outgoing = true, body = "B")
standardMessage(outgoing = true, body = "C")
}
}
}
@Test
fun individualChat() {
backupTest {
individualChat(aci = ALICE_ACI, givenName = "Alice") {
val m1 = standardMessage(outgoing = true, body = "Outgoing 1")
val m2 = standardMessage(outgoing = false, body = "Incoming 1", read = true)
standardMessage(outgoing = true, body = "Outgoing 2", quotes = m2)
standardMessage(outgoing = false, body = "Incoming 2", quotes = m1, quoteTargetMissing = true, read = false)
standardMessage(outgoing = true, body = "Outgoing 3, with mention", randomMention = true)
standardMessage(outgoing = false, body = "Incoming 3, with style", read = false, randomStyling = true)
remoteDeletedMessage(outgoing = true)
remoteDeletedMessage(outgoing = false)
}
}
}
@Test
fun individualRecipients() {
backupTest {
// Comprehensive example
individualRecipient(
aci = ALICE_ACI,
pni = ALICE_PNI,
e164 = ALICE_E164,
givenName = "Alice",
familyName = "Smith",
username = "alice.99",
hidden = false,
registeredState = RecipientTable.RegisteredState.REGISTERED,
profileKey = ProfileKey(Random.nextBytes(32)),
profileSharing = true,
hideStory = false
)
// Trying to get coverage of all the various values
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.NOT_REGISTERED)
individualRecipient(aci = ACI.from(UUID.randomUUID()), registeredState = RecipientTable.RegisteredState.UNKNOWN)
individualRecipient(pni = PNI.from(UUID.randomUUID()))
individualRecipient(e164 = "+15551234567")
individualRecipient(aci = ACI.from(UUID.randomUUID()), givenName = "Bob")
individualRecipient(aci = ACI.from(UUID.randomUUID()), familyName = "Smith")
individualRecipient(aci = ACI.from(UUID.randomUUID()), profileSharing = false)
individualRecipient(aci = ACI.from(UUID.randomUUID()), hideStory = true)
individualRecipient(aci = ACI.from(UUID.randomUUID()), hidden = true)
}
}
@Test
fun accountData() {
val context = ApplicationDependencies.getApplication()
backupTest(validateKeyValue = true) {
val self = Recipient.self()
// 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()
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/")
SignalStore.donationsValues().markUserManuallyCancelled()
SignalStore.donationsValues().setSubscriber(Subscriber(SubscriberId.generate(), "USD"))
SignalStore.donationsValues().setDisplayBadgesOnProfile(false)
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
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.emojiValues().reactions = listOf("a", "b", "c")
TextSecurePreferences.setTypingIndicatorsEnabled(context, false)
TextSecurePreferences.setReadReceiptsEnabled(context, false)
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, true)
}
// Have to check TextSecurePreferences ourselves, since they're not in a database
TextSecurePreferences.isTypingIndicatorsEnabled(context) assertIs false
TextSecurePreferences.isReadReceiptsEnabled(context) assertIs false
TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context) assertIs true
}
/**
* Sets up the database, then executes your setup code, then compares snapshots of the database
* before an after an import to ensure that no data was lost/changed.
*
* @param validateKeyValue If true, this will also validate the KeyValueDatabase. You only want to do this if you
* intend on setting most of the values. Otherwise stuff tends to not match since values are lazily written.
*/
private fun backupTest(validateKeyValue: Boolean = false, content: () -> Unit) {
// Under normal circumstances, My Story ends up being the first recipient in the table, and is added automatically.
// This screws with the tests by offsetting all the recipientIds in the initial state.
// Easiest way to get around this is to make the DB a true clean slate by clearing everything.
// (We only really need to clear Recipient/dlists, but doing everything to be consistent.)
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
SignalDatabase.recipients.clearAllDataForBackupRestore()
SignalDatabase.messages.clearAllDataForBackupRestore()
SignalDatabase.threads.clearAllDataForBackupRestore()
// Again, for comparison purposes, because we always import self first, we want to ensure it's the first item
// in the table when we export.
individualRecipient(
aci = SELF_ACI,
pni = SELF_PNI,
e164 = SELF_E164,
profileKey = SELF_PROFILE_KEY,
profileSharing = true
)
content()
val startingMainData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
val startingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
val exported: ByteArray = BackupRepository.export()
BackupRepository.import(length = exported.size.toLong(), inputStreamFactory = { ByteArrayInputStream(exported) }, selfData = BackupRepository.SelfData(SELF_ACI, SELF_PNI, SELF_E164, SELF_PROFILE_KEY))
val endingData: DatabaseData = SignalDatabase.rawDatabase.readAllContents()
val endingKeyValueData: DatabaseData = if (validateKeyValue) SignalDatabase.rawDatabase.readAllContents() else emptyMap()
assertDatabaseMatches(startingMainData, endingData)
assertDatabaseMatches(startingKeyValueData, endingKeyValueData)
}
private fun individualChat(aci: ACI, givenName: String, familyName: String? = null, init: IndividualChatCreator.() -> Unit) {
val recipientId = individualRecipient(aci = aci, givenName = givenName, familyName = familyName, profileSharing = true)
val threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipientId, false)
IndividualChatCreator(SignalDatabase.rawDatabase, recipientId, threadId).init()
SignalDatabase.threads.update(threadId, false)
}
private fun individualRecipient(
aci: ACI? = null,
pni: PNI? = null,
e164: String? = null,
givenName: String? = null,
familyName: String? = null,
username: String? = null,
hidden: Boolean = false,
registeredState: RecipientTable.RegisteredState = RecipientTable.RegisteredState.UNKNOWN,
profileKey: ProfileKey? = null,
profileSharing: Boolean = false,
hideStory: Boolean = false
): RecipientId {
check(aci != null || pni != null || e164 != null)
val recipientId = SignalDatabase.recipients.getAndPossiblyMerge(aci, pni, e164, pniVerified = true, changeSelf = true)
if (givenName != null || familyName != null) {
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts(givenName, familyName))
}
if (username != null) {
SignalDatabase.recipients.setUsername(recipientId, username)
}
if (registeredState == RecipientTable.RegisteredState.REGISTERED) {
SignalDatabase.recipients.markRegistered(recipientId, aci ?: pni!!)
} else if (registeredState == RecipientTable.RegisteredState.NOT_REGISTERED) {
SignalDatabase.recipients.markUnregistered(recipientId)
}
if (profileKey != null) {
SignalDatabase.recipients.setProfileKey(recipientId, profileKey)
}
SignalDatabase.recipients.setProfileSharing(recipientId, profileSharing)
SignalDatabase.recipients.setHideStory(recipientId, hideStory)
if (hidden) {
SignalDatabase.recipients.markHidden(recipientId)
}
return recipientId
}
private inner class IndividualChatCreator(
private val db: SQLiteDatabase,
private val recipientId: RecipientId,
private val threadId: Long
) {
fun standardMessage(
outgoing: Boolean,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp,
body: String? = null,
read: Boolean = true,
quotes: Long? = null,
quoteTargetMissing: Boolean = false,
randomMention: Boolean = false,
randomStyling: Boolean = false
): Long {
return db.insertMessage(
from = if (outgoing) Recipient.self().id else recipientId,
to = if (outgoing) recipientId else Recipient.self().id,
outgoing = outgoing,
threadId = threadId,
sentTimestamp = sentTimestamp,
receivedTimestamp = receivedTimestamp,
serverTimestamp = serverTimestamp,
body = body,
read = read,
quotes = quotes,
quoteTargetMissing = quoteTargetMissing,
randomMention = randomMention,
randomStyling = randomStyling
)
}
fun remoteDeletedMessage(
outgoing: Boolean,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp
): Long {
return db.insertMessage(
from = if (outgoing) Recipient.self().id else recipientId,
to = if (outgoing) recipientId else Recipient.self().id,
outgoing = outgoing,
threadId = threadId,
sentTimestamp = sentTimestamp,
receivedTimestamp = receivedTimestamp,
serverTimestamp = serverTimestamp,
remoteDeleted = true
)
}
}
private fun SQLiteDatabase.insertMessage(
from: RecipientId,
to: RecipientId,
outgoing: Boolean,
threadId: Long,
sentTimestamp: Long = System.currentTimeMillis(),
receivedTimestamp: Long = if (outgoing) sentTimestamp else sentTimestamp + 1,
serverTimestamp: Long = sentTimestamp,
body: String? = null,
read: Boolean = true,
quotes: Long? = null,
quoteTargetMissing: Boolean = false,
randomMention: Boolean = false,
randomStyling: Boolean = false,
remoteDeleted: Boolean = false
): Long {
val type = if (outgoing) {
MessageTypes.BASE_SENT_TYPE
} else {
MessageTypes.BASE_INBOX_TYPE
} or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
val contentValues = ContentValues()
contentValues.put(MessageTable.DATE_SENT, sentTimestamp)
contentValues.put(MessageTable.DATE_RECEIVED, receivedTimestamp)
contentValues.put(MessageTable.FROM_RECIPIENT_ID, from.serialize())
contentValues.put(MessageTable.TO_RECIPIENT_ID, to.serialize())
contentValues.put(MessageTable.THREAD_ID, threadId)
contentValues.put(MessageTable.BODY, body)
contentValues.put(MessageTable.TYPE, type)
contentValues.put(MessageTable.READ, if (read) 1 else 0)
if (!outgoing) {
contentValues.put(MessageTable.DATE_SERVER, serverTimestamp)
}
if (remoteDeleted) {
contentValues.put(MessageTable.REMOTE_DELETED, 1)
return this
.insertInto(MessageTable.TABLE_NAME)
.values(contentValues)
.run()
}
if (quotes != null) {
val quoteDetails = this.getQuoteDetailsFor(quotes)
contentValues.put(MessageTable.QUOTE_ID, if (quoteTargetMissing) MessageTable.QUOTE_TARGET_MISSING_ID else quoteDetails.quotedSentTimestamp)
contentValues.put(MessageTable.QUOTE_AUTHOR, quoteDetails.authorId.serialize())
contentValues.put(MessageTable.QUOTE_BODY, quoteDetails.body)
contentValues.put(MessageTable.QUOTE_BODY_RANGES, quoteDetails.bodyRanges)
contentValues.put(MessageTable.QUOTE_TYPE, quoteDetails.type)
contentValues.put(MessageTable.QUOTE_MISSING, quoteTargetMissing.toInt())
}
if (body != null && (randomMention || randomStyling)) {
val ranges: MutableList<BodyRangeList.BodyRange> = mutableListOf()
if (randomMention) {
ranges += BodyRangeList.BodyRange(
start = 0,
length = Random.nextInt(body.length),
mentionUuid = if (outgoing) Recipient.resolved(to).requireAci().toString() else Recipient.resolved(from).requireAci().toString()
)
}
if (randomStyling) {
ranges += BodyRangeList.BodyRange(
start = 0,
length = Random.nextInt(body.length),
style = BodyRangeList.BodyRange.Style.fromValue(Random.nextInt(BodyRangeList.BodyRange.Style.values().size))
)
}
contentValues.put(MessageTable.MESSAGE_RANGES, BodyRangeList(ranges = ranges).encode())
}
return this
.insertInto(MessageTable.TABLE_NAME)
.values(contentValues)
.run()
}
private fun assertDatabaseMatches(expected: DatabaseData, actual: DatabaseData) {
assert(expected.keys.size == actual.keys.size) { "Mismatched table count! Expected: ${expected.keys} || Actual: ${actual.keys}" }
assert(expected.keys.containsAll(actual.keys)) { "Table names differ! Expected: ${expected.keys} || Actual: ${actual.keys}" }
val tablesToCheck = expected.keys.filter { !IGNORED_TABLES.contains(it) }
for (table in tablesToCheck) {
val expectedTable: List<Map<String, Any?>> = expected[table]!!
val actualTable: List<Map<String, Any?>> = actual[table]!!
assert(expectedTable.size == actualTable.size) { "Mismatched number of rows for table '$table'! Expected: ${expectedTable.size} || Actual: ${actualTable.size}\n $actualTable" }
val expectedFiltered: List<Map<String, Any?>> = expectedTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
val actualFiltered: List<Map<String, Any?>> = actualTable.withoutExcludedColumns(IGNORED_COLUMNS[table])
assert(contentEquals(expectedFiltered, actualFiltered)) { "Data did not match for table '$table'!\n${prettyDiff(expectedFiltered, actualFiltered)}" }
}
}
private fun contentEquals(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): Boolean {
if (expectedRows == actualRows) {
return true
}
assert(expectedRows.size == actualRows.size)
for (i in expectedRows.indices) {
val expectedRow = expectedRows[i]
val actualRow = actualRows[i]
for (key in expectedRow.keys) {
val expectedValue = expectedRow[key]
val actualValue = actualRow[key]
if (!contentEquals(expectedValue, actualValue)) {
return false
}
}
}
return true
}
private fun contentEquals(lhs: Any?, rhs: Any?): Boolean {
return if (lhs is ByteArray && rhs is ByteArray) {
lhs.contentEquals(rhs)
} else {
lhs == rhs
}
}
private fun prettyDiff(expectedRows: List<Map<String, Any?>>, actualRows: List<Map<String, Any?>>): String {
val builder = StringBuilder()
assert(expectedRows.size == actualRows.size)
for (i in expectedRows.indices) {
val expectedRow = expectedRows[i]
val actualRow = actualRows[i]
var describedRow = false
for (key in expectedRow.keys) {
val expectedValue = expectedRow[key]
val actualValue = actualRow[key]
if (!contentEquals(expectedValue, actualValue)) {
if (!describedRow) {
builder.append("-- ROW ${i + 1}\n")
describedRow = true
}
builder.append("  [$key] Expected: ${expectedValue.prettyPrint()} || Actual: ${actualValue.prettyPrint()} \n")
}
}
if (describedRow) {
builder.append("\n")
builder.append("Expected: $expectedRow\n")
builder.append("Actual: $actualRow\n")
}
}
return builder.toString()
}
private fun Any?.prettyPrint(): String {
return when (this) {
is ByteArray -> "Bytes(${Hex.toString(this)})"
else -> this.toString()
}
}
private fun List<Map<String, Any?>>.withoutExcludedColumns(ignored: Set<String>?): List<Map<String, Any?>> {
return if (ignored != null) {
this.map { row ->
row.filterKeys { !ignored.contains(it) }
}
} else {
this
}
}
private fun SQLiteDatabase.getQuoteDetailsFor(messageId: Long): QuoteDetails {
return this
.select(
MessageTable.DATE_SENT,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.BODY,
MessageTable.MESSAGE_RANGES
)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.ID} = ?", messageId)
.run()
.readToSingleObject { cursor ->
QuoteDetails(
quotedSentTimestamp = cursor.requireLong(MessageTable.DATE_SENT),
authorId = RecipientId.from(cursor.requireLong(MessageTable.FROM_RECIPIENT_ID)),
body = cursor.requireString(MessageTable.BODY),
bodyRanges = cursor.requireBlob(MessageTable.MESSAGE_RANGES),
type = QuoteModel.Type.NORMAL.code
)
}!!
}
private fun SQLiteDatabase.readAllContents(): DatabaseData {
return SqlUtil.getAllTables(this).associateWith { table -> this.getAllTableData(table) }
}
private fun SQLiteDatabase.getAllTableData(table: String): List<Map<String, Any?>> {
return this
.select()
.from(table)
.run()
.readToList { cursor ->
val map: MutableMap<String, Any?> = mutableMapOf()
for (i in 0 until cursor.columnCount) {
val column = cursor.getColumnName(i)
when (cursor.getType(i)) {
Cursor.FIELD_TYPE_INTEGER -> map[column] = cursor.getInt(i)
Cursor.FIELD_TYPE_FLOAT -> map[column] = cursor.getFloat(i)
Cursor.FIELD_TYPE_STRING -> map[column] = cursor.getString(i)
Cursor.FIELD_TYPE_BLOB -> map[column] = cursor.getBlob(i)
Cursor.FIELD_TYPE_NULL -> map[column] = null
}
}
map
}
}
private data class QuoteDetails(
val quotedSentTimestamp: Long,
val authorId: RecipientId,
val body: String?,
val bodyRanges: ByteArray?,
val type: Int
)
}

View File

@@ -1,181 +0,0 @@
package org.thoughtcrime.securesms.jobs
import androidx.test.ext.junit.runners.AndroidJUnit4
import okhttp3.mockwebserver.MockResponse
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.libsignal.usernames.Username
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.testing.Delete
import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Put
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.thoughtcrime.securesms.testing.failure
import org.thoughtcrime.securesms.testing.success
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
@get:Rule
val harness = SignalActivityRule()
@After
fun tearDown() {
InstrumentationApplicationDependencyProvider.clearHandlers()
SignalStore.account().usernameOutOfSync = false
}
@Test
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
// GIVEN
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Delete("/v1/accounts/username_hash") { MockResponse().success() }
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
}
@Test
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
val serverUsername = "hello.3232"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(serverUsername))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(WhoAmIResponse())
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertTrue(didConfirm)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(username))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertFalse(didReserve)
assertFalse(didConfirm)
assertFalse(SignalStore.account().usernameOutOfSync)
}
@Test
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
// GIVEN
var didReserve = false
var didConfirm = false
val username = "hello.32"
SignalDatabase.recipients.setUsername(harness.self.id, username)
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash("${username}23"))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().failure(418)
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
MockResponse().success()
}
)
// WHEN
RefreshOwnProfileJob.checkUsernameIsInSync()
// THEN
assertTrue(didReserve)
assertFalse(didConfirm)
assertTrue(SignalStore.account().usernameOutOfSync)
}
}

View File

@@ -165,7 +165,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
override fun storeSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?, record: SenderKeyRecord?) = throw UnsupportedOperationException()
override fun loadSenderKey(sender: SignalProtocolAddress?, distributionId: UUID?): SenderKeyRecord = throw UnsupportedOperationException()
override fun archiveSession(address: SignalProtocolAddress?) = throw UnsupportedOperationException()
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
override fun getAllAddressesWithActiveSessions(addressNames: MutableList<String>?): MutableMap<SignalProtocolAddress, SessionRecord> = throw UnsupportedOperationException()
override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet<SignalProtocolAddress> = throw UnsupportedOperationException()
override fun markSenderKeySharedWith(distributionId: DistributionId?, addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()
override fun clearSenderKeySharedWith(addresses: MutableCollection<SignalProtocolAddress>?) = throw UnsupportedOperationException()

View File

@@ -2,9 +2,10 @@ package org.signal.benchmark.setup
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.TestDbUtils
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
@@ -65,7 +66,8 @@ object TestMessages {
return insert
}
fun insertIncomingTextMessage(other: Recipient, body: String, timestamp: Long? = null) {
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
body = body,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
@@ -73,10 +75,11 @@ object TestMessages {
receivedTimeMillis = timestamp ?: System.currentTimeMillis()
)
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get().messageId
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get().messageId
}
fun insertIncomingQuoteTextMessage(other: Recipient, body: String, quote: QuoteModel, timestamp: Long?) {
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
body = body,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
@@ -90,28 +93,30 @@ object TestMessages {
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
imageAttachment()
}
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
return insertIncomingMediaMessage(recipient = other, message = message, failed = failed)
return insertIncomingMessage(recipient = other, message = message, failed = failed)
}
fun insertIncomingVoiceMessage(other: Recipient, timestamp: Long? = null): Long {
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
sentTimeMillis = timestamp ?: System.currentTimeMillis(),
serverTimeMillis = timestamp ?: System.currentTimeMillis(),
receivedTimeMillis = timestamp ?: System.currentTimeMillis(),
attachments = PointerAttachment.forPointers(Optional.of(Collections.singletonList(voiceAttachment()) as List<SignalServiceAttachment>))
)
return insertIncomingMediaMessage(recipient = other, message = message, failed = false)
return insertIncomingMessage(recipient = other, message = message, failed = false)
}
private fun insertIncomingMediaMessage(recipient: Recipient, message: IncomingMediaMessage, failed: Boolean = false): Long {
private fun insertIncomingMessage(recipient: Recipient, message: IncomingMessage, failed: Boolean = false): Long {
val id = insertIncomingMessage(recipient = recipient, message = message)
if (failed) {
setMessageMediaFailed(id)
@@ -122,8 +127,8 @@ object TestMessages {
return id
}
private fun insertIncomingMessage(recipient: Recipient, message: IncomingMediaMessage): Long {
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(recipient)).get().messageId
private fun insertIncomingMessage(recipient: Recipient, message: IncomingMessage): Long {
return SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(recipient)).get().messageId
}
private fun setMessageMediaFailed(messageId: Long) {

View File

@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.SlideDeck
@@ -78,7 +78,7 @@ class ConversationElementGenerator {
val isIncoming = random.nextBoolean()
val record = MediaMmsMessageRecord(
val record = MmsMessageRecord(
messageId,
if (isIncoming) Recipient.UNKNOWN else Recipient.self(),
0,
@@ -86,7 +86,7 @@ class ConversationElementGenerator {
now,
now,
now,
1,
true,
1,
testMessage,
SlideDeck(),
@@ -97,7 +97,7 @@ class ConversationElementGenerator {
0,
0,
false,
1,
true,
null,
emptyList(),
emptyList(),
@@ -106,7 +106,7 @@ class ConversationElementGenerator {
false,
false,
now,
1,
true,
now,
null,
StoryType.NONE,

View File

@@ -594,6 +594,13 @@
android:host="signal.group"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="signaldonations.org" android:pathPrefix="/stripe/return/ideal"/>
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -963,7 +970,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".profiles.edit.EditProfileActivity"
<activity android:name=".profiles.edit.CreateProfileActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
@@ -973,7 +980,7 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".profiles.manage.ManageProfileActivity"
<activity android:name=".profiles.manage.EditProfileActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
@@ -1189,6 +1196,10 @@
android:name=".service.GenericForegroundService"
android:exported="false"/>
<service
android:name=".service.AttachmentProgressService"
android:exported="false"/>
<service
android:name=".gcm.FcmFetchBackgroundService"
android:exported="false"/>

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
package com.google.android.material.bottomsheet
import android.view.View
import android.widget.FrameLayout
import java.lang.ref.WeakReference
/**
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
*/
object BottomSheetBehaviorHack {
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) {
fun <T : View> setNestedScrollingChild(behavior: BottomSheetBehavior<T>, view: View) {
behavior.nestedScrollingChildRef = WeakReference(view)
}
}

View File

@@ -0,0 +1,800 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.conscrypt;
import java.nio.ByteBuffer;
import java.security.KeyManagementException;
import java.security.PrivateKey;
import java.security.Provider;
import java.security.cert.X509Certificate;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLContextSpi;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLEngineResult;
import javax.net.ssl.SSLException;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSessionContext;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* Core API for creating and configuring all Conscrypt types.
* This is identical to the original Conscrypt.java, except with the slow
* version initialization code removed.
*/
@SuppressWarnings("unused")
public final class ConscryptSignal {
private ConscryptSignal() {}
/**
* Returns {@code true} if the Conscrypt native library has been successfully loaded.
*/
public static boolean isAvailable() {
try {
checkAvailability();
return true;
} catch (Throwable e) {
return false;
}
}
// BEGIN MODIFICATION
/*public static class Version {
private final int major;
private final int minor;
private final int patch;
private Version(int major, int minor, int patch) {
this.major = major;
this.minor = minor;
this.patch = patch;
}
public int major() { return major; }
public int minor() { return minor; }
public int patch() { return patch; }
}
private static final Version VERSION;
static {
int major = -1;
int minor = -1;
int patch = -1;
InputStream stream = null;
try {
stream = Conscrypt.class.getResourceAsStream("conscrypt.properties");
if (stream != null) {
Properties props = new Properties();
props.load(stream);
major = Integer.parseInt(props.getProperty("org.conscrypt.version.major", "-1"));
minor = Integer.parseInt(props.getProperty("org.conscrypt.version.minor", "-1"));
patch = Integer.parseInt(props.getProperty("org.conscrypt.version.patch", "-1"));
}
} catch (IOException e) {
// TODO(prb): This should probably be fatal or have some fallback behaviour
} finally {
IoUtils.closeQuietly(stream);
}
if ((major >= 0) && (minor >= 0) && (patch >= 0)) {
VERSION = new Version(major, minor, patch);
} else {
VERSION = null;
}
}
/**
* Returns the version of this distribution of Conscrypt. If version information is
* unavailable, returns {@code null}.
*/
/*public static Version version() {
return VERSION;
}*/
// END MODIFICATION
/**
* Checks that the Conscrypt support is available for the system.
*
* @throws UnsatisfiedLinkError if unavailable
*/
public static void checkAvailability() {
NativeCrypto.checkAvailability();
}
/**
* Indicates whether the given {@link Provider} was created by this distribution of Conscrypt.
*/
public static boolean isConscrypt(Provider provider) {
return provider instanceof OpenSSLProvider;
}
/**
* Constructs a new {@link Provider} with the default name.
*/
public static Provider newProvider() {
checkAvailability();
return new OpenSSLProvider();
}
/**
* Constructs a new {@link Provider} with the given name.
*
* @deprecated Use {@link #newProviderBuilder()} instead.
*/
@Deprecated
public static Provider newProvider(String providerName) {
checkAvailability();
return newProviderBuilder().setName(providerName).build();
}
public static class ProviderBuilder {
private String name = Platform.getDefaultProviderName();
private boolean provideTrustManager = Platform.provideTrustManagerByDefault();
private String defaultTlsProtocol = NativeCrypto.SUPPORTED_PROTOCOL_TLSV1_3;
private ProviderBuilder() {}
/**
* Sets the name of the Provider to be built.
*/
public ProviderBuilder setName(String name) {
this.name = name;
return this;
}
/**
* Causes the returned provider to provide an implementation of
* {@link javax.net.ssl.TrustManagerFactory}.
* @deprecated Use provideTrustManager(true)
*/
@Deprecated
public ProviderBuilder provideTrustManager() {
return provideTrustManager(true);
}
/**
* Specifies whether the returned provider will provide an implementation of
* {@link javax.net.ssl.TrustManagerFactory}.
*/
public ProviderBuilder provideTrustManager(boolean provide) {
this.provideTrustManager = provide;
return this;
}
/**
* Specifies what the default TLS protocol should be for SSLContext identifiers
* {@code TLS}, {@code SSL}, and {@code Default}.
*/
public ProviderBuilder defaultTlsProtocol(String defaultTlsProtocol) {
this.defaultTlsProtocol = defaultTlsProtocol;
return this;
}
public Provider build() {
return new OpenSSLProvider(name, provideTrustManager, defaultTlsProtocol);
}
}
public static ProviderBuilder newProviderBuilder() {
return new ProviderBuilder();
}
/**
* Returns the maximum length (in bytes) of an encrypted packet.
*/
public static int maxEncryptedPacketLength() {
return NativeConstants.SSL3_RT_MAX_PACKET_SIZE;
}
/**
* Gets the default X.509 trust manager.
*/
@ExperimentalApi
public static X509TrustManager getDefaultX509TrustManager() throws KeyManagementException {
checkAvailability();
return SSLParametersImpl.getDefaultX509TrustManager();
}
/**
* Indicates whether the given {@link SSLContext} was created by this distribution of Conscrypt.
*/
public static boolean isConscrypt(SSLContext context) {
return context.getProvider() instanceof OpenSSLProvider;
}
/**
* Constructs a new instance of the preferred {@link SSLContextSpi}.
*/
public static SSLContextSpi newPreferredSSLContextSpi() {
checkAvailability();
return OpenSSLContextImpl.getPreferred();
}
/**
* Sets the client-side persistent cache to be used by the context.
*/
public static void setClientSessionCache(SSLContext context, SSLClientSessionCache cache) {
SSLSessionContext clientContext = context.getClientSessionContext();
if (!(clientContext instanceof ClientSessionContext)) {
throw new IllegalArgumentException(
"Not a conscrypt client context: " + clientContext.getClass().getName());
}
((ClientSessionContext) clientContext).setPersistentCache(cache);
}
/**
* Sets the server-side persistent cache to be used by the context.
*/
public static void setServerSessionCache(SSLContext context, SSLServerSessionCache cache) {
SSLSessionContext serverContext = context.getServerSessionContext();
if (!(serverContext instanceof ServerSessionContext)) {
throw new IllegalArgumentException(
"Not a conscrypt client context: " + serverContext.getClass().getName());
}
((ServerSessionContext) serverContext).setPersistentCache(cache);
}
/**
* Indicates whether the given {@link SSLSocketFactory} was created by this distribution of
* Conscrypt.
*/
public static boolean isConscrypt(SSLSocketFactory factory) {
return factory instanceof OpenSSLSocketFactoryImpl;
}
private static OpenSSLSocketFactoryImpl toConscrypt(SSLSocketFactory factory) {
if (!isConscrypt(factory)) {
throw new IllegalArgumentException(
"Not a conscrypt socket factory: " + factory.getClass().getName());
}
return (OpenSSLSocketFactoryImpl) factory;
}
/**
* Configures the default socket to be created for all socket factory instances.
*/
@ExperimentalApi
public static void setUseEngineSocketByDefault(boolean useEngineSocket) {
OpenSSLSocketFactoryImpl.setUseEngineSocketByDefault(useEngineSocket);
OpenSSLServerSocketFactoryImpl.setUseEngineSocketByDefault(useEngineSocket);
}
/**
* Configures the socket to be created for the given socket factory instance.
*/
@ExperimentalApi
public static void setUseEngineSocket(SSLSocketFactory factory, boolean useEngineSocket) {
toConscrypt(factory).setUseEngineSocket(useEngineSocket);
}
/**
* Indicates whether the given {@link SSLServerSocketFactory} was created by this distribution
* of Conscrypt.
*/
public static boolean isConscrypt(SSLServerSocketFactory factory) {
return factory instanceof OpenSSLServerSocketFactoryImpl;
}
private static OpenSSLServerSocketFactoryImpl toConscrypt(SSLServerSocketFactory factory) {
if (!isConscrypt(factory)) {
throw new IllegalArgumentException(
"Not a conscrypt server socket factory: " + factory.getClass().getName());
}
return (OpenSSLServerSocketFactoryImpl) factory;
}
/**
* Configures the socket to be created for the given server socket factory instance.
*/
@ExperimentalApi
public static void setUseEngineSocket(SSLServerSocketFactory factory, boolean useEngineSocket) {
toConscrypt(factory).setUseEngineSocket(useEngineSocket);
}
/**
* Indicates whether the given {@link SSLSocket} was created by this distribution of Conscrypt.
*/
public static boolean isConscrypt(SSLSocket socket) {
return socket instanceof AbstractConscryptSocket;
}
private static AbstractConscryptSocket toConscrypt(SSLSocket socket) {
if (!isConscrypt(socket)) {
throw new IllegalArgumentException(
"Not a conscrypt socket: " + socket.getClass().getName());
}
return (AbstractConscryptSocket) socket;
}
/**
* This method enables Server Name Indication (SNI) and overrides the hostname supplied
* during socket creation. If the hostname is not a valid SNI hostname, the SNI extension
* will be omitted from the handshake.
*
* @param socket the socket
* @param hostname the desired SNI hostname, or null to disable
*/
public static void setHostname(SSLSocket socket, String hostname) {
toConscrypt(socket).setHostname(hostname);
}
/**
* Returns either the hostname supplied during socket creation or via
* {@link #setHostname(SSLSocket, String)}. No DNS resolution is attempted before
* returning the hostname.
*/
public static String getHostname(SSLSocket socket) {
return toConscrypt(socket).getHostname();
}
/**
* This method attempts to create a textual representation of the peer host or IP. Does
* not perform a reverse DNS lookup. This is typically used during session creation.
*/
public static String getHostnameOrIP(SSLSocket socket) {
return toConscrypt(socket).getHostnameOrIP();
}
/**
* This method enables session ticket support.
*
* @param socket the socket
* @param useSessionTickets True to enable session tickets
*/
public static void setUseSessionTickets(SSLSocket socket, boolean useSessionTickets) {
toConscrypt(socket).setUseSessionTickets(useSessionTickets);
}
/**
* Enables/disables TLS Channel ID for the given server-side socket.
*
* <p>This method needs to be invoked before the handshake starts.
*
* @param socket the socket
* @param enabled Whether to enable channel ID.
* @throws IllegalStateException if this is a client socket or if the handshake has already
* started.
*/
public static void setChannelIdEnabled(SSLSocket socket, boolean enabled) {
toConscrypt(socket).setChannelIdEnabled(enabled);
}
/**
* Gets the TLS Channel ID for the given server-side socket. Channel ID is only available
* once the handshake completes.
*
* @param socket the socket
* @return channel ID or {@code null} if not available.
* @throws IllegalStateException if this is a client socket or if the handshake has not yet
* completed.
* @throws SSLException if channel ID is available but could not be obtained.
*/
public static byte[] getChannelId(SSLSocket socket) throws SSLException {
return toConscrypt(socket).getChannelId();
}
/**
* Sets the {@link PrivateKey} to be used for TLS Channel ID by this client socket.
*
* <p>This method needs to be invoked before the handshake starts.
*
* @param socket the socket
* @param privateKey private key (enables TLS Channel ID) or {@code null} for no key
* (disables TLS Channel ID).
* The private key must be an Elliptic Curve (EC) key based on the NIST P-256 curve (aka
* SECG secp256r1 or ANSI
* X9.62 prime256v1).
* @throws IllegalStateException if this is a server socket or if the handshake has already
* started.
*/
public static void setChannelIdPrivateKey(SSLSocket socket, PrivateKey privateKey) {
toConscrypt(socket).setChannelIdPrivateKey(privateKey);
}
/**
* Returns the ALPN protocol agreed upon by client and server.
*
* @param socket the socket
* @return the selected protocol or {@code null} if no protocol was agreed upon.
*/
public static String getApplicationProtocol(SSLSocket socket) {
return toConscrypt(socket).getApplicationProtocol();
}
/**
* Sets an application-provided ALPN protocol selector. If provided, this will override
* the list of protocols set by {@link #setApplicationProtocols(SSLSocket, String[])}.
*
* @param socket the socket
* @param selector the ALPN protocol selector
*/
public static void setApplicationProtocolSelector(SSLSocket socket,
ApplicationProtocolSelector selector) {
toConscrypt(socket).setApplicationProtocolSelector(selector);
}
/**
* Sets the application-layer protocols (ALPN) in prioritization order.
*
* @param socket the socket being configured
* @param protocols the protocols in descending order of preference. If empty, no protocol
* indications will be used. This array will be copied.
* @throws IllegalArgumentException - if protocols is null, or if any element in a non-empty
* array is null or an empty (zero-length) string
*/
public static void setApplicationProtocols(SSLSocket socket, String[] protocols) {
toConscrypt(socket).setApplicationProtocols(protocols);
}
/**
* Gets the application-layer protocols (ALPN) in prioritization order.
*
* @param socket the socket
* @return the protocols in descending order of preference, or an empty array if protocol
* indications are not being used. Always returns a new array.
*/
public static String[] getApplicationProtocols(SSLSocket socket) {
return toConscrypt(socket).getApplicationProtocols();
}
/**
* Returns the tls-unique channel binding value for this connection, per RFC 5929. This
* will return {@code null} if there is no such value available, such as if the handshake
* has not yet completed or this connection is closed.
*/
public static byte[] getTlsUnique(SSLSocket socket) {
return toConscrypt(socket).getTlsUnique();
}
/**
* Exports a value derived from the TLS master secret as described in RFC 5705.
*
* @param label the label to use in calculating the exported value. This must be
* an ASCII-only string.
* @param context the application-specific context value to use in calculating the
* exported value. This may be {@code null} to use no application context, which is
* treated differently than an empty byte array.
* @param length the number of bytes of keying material to return.
* @return a value of the specified length, or {@code null} if the handshake has not yet
* completed or the connection has been closed.
* @throws SSLException if the value could not be exported.
*/
public static byte[] exportKeyingMaterial(SSLSocket socket, String label, byte[] context,
int length) throws SSLException {
return toConscrypt(socket).exportKeyingMaterial(label, context, length);
}
/**
* Indicates whether the given {@link SSLEngine} was created by this distribution of Conscrypt.
*/
public static boolean isConscrypt(SSLEngine engine) {
return engine instanceof AbstractConscryptEngine;
}
private static AbstractConscryptEngine toConscrypt(SSLEngine engine) {
if (!isConscrypt(engine)) {
throw new IllegalArgumentException(
"Not a conscrypt engine: " + engine.getClass().getName());
}
return (AbstractConscryptEngine) engine;
}
/**
* Provides the given engine with the provided bufferAllocator.
* @throws IllegalArgumentException if the provided engine is not a Conscrypt engine.
* @throws IllegalStateException if the provided engine has already begun its handshake.
*/
@ExperimentalApi
public static void setBufferAllocator(SSLEngine engine, BufferAllocator bufferAllocator) {
toConscrypt(engine).setBufferAllocator(bufferAllocator);
}
/**
* Provides the given socket with the provided bufferAllocator. If the given socket is a
* Conscrypt socket but does not use buffer allocators, this method does nothing.
* @throws IllegalArgumentException if the provided socket is not a Conscrypt socket.
* @throws IllegalStateException if the provided socket has already begun its handshake.
*/
@ExperimentalApi
public static void setBufferAllocator(SSLSocket socket, BufferAllocator bufferAllocator) {
AbstractConscryptSocket s = toConscrypt(socket);
if (s instanceof ConscryptEngineSocket) {
((ConscryptEngineSocket) s).setBufferAllocator(bufferAllocator);
}
}
/**
* Configures the default {@link BufferAllocator} to be used by all future
* {@link SSLEngine} instances from this provider.
*/
@ExperimentalApi
public static void setDefaultBufferAllocator(BufferAllocator bufferAllocator) {
ConscryptEngine.setDefaultBufferAllocator(bufferAllocator);
}
/**
* This method enables Server Name Indication (SNI) and overrides the hostname supplied
* during engine creation.
*
* @param engine the engine
* @param hostname the desired SNI hostname, or {@code null} to disable
*/
public static void setHostname(SSLEngine engine, String hostname) {
toConscrypt(engine).setHostname(hostname);
}
/**
* Returns either the hostname supplied during socket creation or via
* {@link #setHostname(SSLEngine, String)}. No DNS resolution is attempted before
* returning the hostname.
*/
public static String getHostname(SSLEngine engine) {
return toConscrypt(engine).getHostname();
}
/**
* Returns the maximum overhead, in bytes, of sealing a record with SSL.
*/
public static int maxSealOverhead(SSLEngine engine) {
return toConscrypt(engine).maxSealOverhead();
}
/**
* Sets a listener on the given engine for completion of the TLS handshake
*/
public static void setHandshakeListener(SSLEngine engine, HandshakeListener handshakeListener) {
toConscrypt(engine).setHandshakeListener(handshakeListener);
}
/**
* Enables/disables TLS Channel ID for the given server-side engine.
*
* <p>This method needs to be invoked before the handshake starts.
*
* @param engine the engine
* @param enabled Whether to enable channel ID.
* @throws IllegalStateException if this is a client engine or if the handshake has already
* started.
*/
public static void setChannelIdEnabled(SSLEngine engine, boolean enabled) {
toConscrypt(engine).setChannelIdEnabled(enabled);
}
/**
* Gets the TLS Channel ID for the given server-side engine. Channel ID is only available
* once the handshake completes.
*
* @param engine the engine
* @return channel ID or {@code null} if not available.
* @throws IllegalStateException if this is a client engine or if the handshake has not yet
* completed.
* @throws SSLException if channel ID is available but could not be obtained.
*/
public static byte[] getChannelId(SSLEngine engine) throws SSLException {
return toConscrypt(engine).getChannelId();
}
/**
* Sets the {@link PrivateKey} to be used for TLS Channel ID by this client engine.
*
* <p>This method needs to be invoked before the handshake starts.
*
* @param engine the engine
* @param privateKey private key (enables TLS Channel ID) or {@code null} for no key
* (disables TLS Channel ID).
* The private key must be an Elliptic Curve (EC) key based on the NIST P-256 curve (aka
* SECG secp256r1 or ANSI X9.62 prime256v1).
* @throws IllegalStateException if this is a server engine or if the handshake has already
* started.
*/
public static void setChannelIdPrivateKey(SSLEngine engine, PrivateKey privateKey) {
toConscrypt(engine).setChannelIdPrivateKey(privateKey);
}
/**
* Extended unwrap method for multiple source and destination buffers.
*
* @param engine the target engine for the unwrap
* @param srcs the source buffers
* @param dsts the destination buffers
* @return the result of the unwrap operation
* @throws SSLException thrown if an SSL error occurred
*/
public static SSLEngineResult unwrap(SSLEngine engine, final ByteBuffer[] srcs,
final ByteBuffer[] dsts) throws SSLException {
return toConscrypt(engine).unwrap(srcs, dsts);
}
/**
* Exteneded unwrap method for multiple source and destination buffers.
*
* @param engine the target engine for the unwrap.
* @param srcs the source buffers
* @param srcsOffset the offset in the {@code srcs} array of the first source buffer
* @param srcsLength the number of source buffers starting at {@code srcsOffset}
* @param dsts the destination buffers
* @param dstsOffset the offset in the {@code dsts} array of the first destination buffer
* @param dstsLength the number of destination buffers starting at {@code dstsOffset}
* @return the result of the unwrap operation
* @throws SSLException thrown if an SSL error occurred
*/
public static SSLEngineResult unwrap(SSLEngine engine, final ByteBuffer[] srcs, int srcsOffset,
final int srcsLength, final ByteBuffer[] dsts, final int dstsOffset,
final int dstsLength) throws SSLException {
return toConscrypt(engine).unwrap(
srcs, srcsOffset, srcsLength, dsts, dstsOffset, dstsLength);
}
/**
* This method enables session ticket support.
*
* @param engine the engine
* @param useSessionTickets True to enable session tickets
*/
public static void setUseSessionTickets(SSLEngine engine, boolean useSessionTickets) {
toConscrypt(engine).setUseSessionTickets(useSessionTickets);
}
/**
* Sets the application-layer protocols (ALPN) in prioritization order.
*
* @param engine the engine being configured
* @param protocols the protocols in descending order of preference. If empty, no protocol
* indications will be used. This array will be copied.
* @throws IllegalArgumentException - if protocols is null, or if any element in a non-empty
* array is null or an empty (zero-length) string
*/
public static void setApplicationProtocols(SSLEngine engine, String[] protocols) {
toConscrypt(engine).setApplicationProtocols(protocols);
}
/**
* Gets the application-layer protocols (ALPN) in prioritization order.
*
* @param engine the engine
* @return the protocols in descending order of preference, or an empty array if protocol
* indications are not being used. Always returns a new array.
*/
public static String[] getApplicationProtocols(SSLEngine engine) {
return toConscrypt(engine).getApplicationProtocols();
}
/**
* Sets an application-provided ALPN protocol selector. If provided, this will override
* the list of protocols set by {@link #setApplicationProtocols(SSLEngine, String[])}.
*
* @param engine the engine
* @param selector the ALPN protocol selector
*/
public static void setApplicationProtocolSelector(SSLEngine engine,
ApplicationProtocolSelector selector) {
toConscrypt(engine).setApplicationProtocolSelector(selector);
}
/**
* Returns the ALPN protocol agreed upon by client and server.
*
* @param engine the engine
* @return the selected protocol or {@code null} if no protocol was agreed upon.
*/
public static String getApplicationProtocol(SSLEngine engine) {
return toConscrypt(engine).getApplicationProtocol();
}
/**
* Returns the tls-unique channel binding value for this connection, per RFC 5929. This
* will return {@code null} if there is no such value available, such as if the handshake
* has not yet completed or this connection is closed.
*/
public static byte[] getTlsUnique(SSLEngine engine) {
return toConscrypt(engine).getTlsUnique();
}
/**
* Exports a value derived from the TLS master secret as described in RFC 5705.
*
* @param label the label to use in calculating the exported value. This must be
* an ASCII-only string.
* @param context the application-specific context value to use in calculating the
* exported value. This may be {@code null} to use no application context, which is
* treated differently than an empty byte array.
* @param length the number of bytes of keying material to return.
* @return a value of the specified length, or {@code null} if the handshake has not yet
* completed or the connection has been closed.
* @throws SSLException if the value could not be exported.
*/
public static byte[] exportKeyingMaterial(SSLEngine engine, String label, byte[] context,
int length) throws SSLException {
return toConscrypt(engine).exportKeyingMaterial(label, context, length);
}
/**
* Indicates whether the given {@link TrustManager} was created by this distribution of
* Conscrypt.
*/
public static boolean isConscrypt(TrustManager trustManager) {
return trustManager instanceof TrustManagerImpl;
}
private static TrustManagerImpl toConscrypt(TrustManager trustManager) {
if (!isConscrypt(trustManager)) {
throw new IllegalArgumentException(
"Not a Conscrypt trust manager: " + trustManager.getClass().getName());
}
return (TrustManagerImpl) trustManager;
}
/**
* Set the default hostname verifier that will be used for HTTPS endpoint identification by
* Conscrypt trust managers. If {@code null} (the default), endpoint identification will use
* the default hostname verifier set in
* {@link HttpsURLConnection#setDefaultHostnameVerifier(javax.net.ssl.HostnameVerifier)}.
*/
public synchronized static void setDefaultHostnameVerifier(ConscryptHostnameVerifier verifier) {
TrustManagerImpl.setDefaultHostnameVerifier(verifier);
}
/**
* Returns the currently-set default hostname verifier for Conscrypt trust managers.
*
* @see #setDefaultHostnameVerifier(ConscryptHostnameVerifier)
*/
public synchronized static ConscryptHostnameVerifier getDefaultHostnameVerifier(TrustManager trustManager) {
return TrustManagerImpl.getDefaultHostnameVerifier();
}
/**
* Set the hostname verifier that will be used for HTTPS endpoint identification by the
* given trust manager. If {@code null} (the default), endpoint identification will use the
* default hostname verifier set in {@link #setDefaultHostnameVerifier(ConscryptHostnameVerifier)}.
*
* @throws IllegalArgumentException if the provided trust manager is not a Conscrypt trust
* manager per {@link #isConscrypt(TrustManager)}
*/
public static void setHostnameVerifier(TrustManager trustManager, ConscryptHostnameVerifier verifier) {
toConscrypt(trustManager).setHostnameVerifier(verifier);
}
/**
* Returns the currently-set hostname verifier for the given trust manager.
*
* @throws IllegalArgumentException if the provided trust manager is not a Conscrypt trust
* manager per {@link #isConscrypt(TrustManager)}
*
* @see #setHostnameVerifier(TrustManager, ConscryptHostnameVerifier)
*/
public static ConscryptHostnameVerifier getHostnameVerifier(TrustManager trustManager) {
return toConscrypt(trustManager).getHostnameVerifier();
}
/**
* Wraps the HttpsURLConnection.HostnameVerifier into a ConscryptHostnameVerifier
*/
public static ConscryptHostnameVerifier wrapHostnameVerifier(final HostnameVerifier verifier) {
return new ConscryptHostnameVerifier() {
@Override
public boolean verify(X509Certificate[] certificates, String hostname, SSLSession session) {
return verifier.verify(hostname, session);
}
};
}
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms
import org.thoughtcrime.securesms.util.FeatureFlags
import org.whispersystems.signalservice.api.account.AccountAttributes
object AppCapabilities {
@@ -17,7 +16,7 @@ object AppCapabilities {
changeNumber = true,
stories = true,
giftBadges = true,
pni = FeatureFlags.phoneNumberPrivacy(),
pni = true,
paymentActivation = true
)
}

View File

@@ -26,13 +26,15 @@ import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
import org.conscrypt.Conscrypt;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
import org.signal.core.util.logging.Scrubber;
import org.signal.core.util.tracing.Tracer;
import org.signal.glide.SignalGlideCodecs;
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
@@ -109,6 +111,7 @@ import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
import io.reactivex.rxjava3.schedulers.Schedulers;
import kotlin.Unit;
import rxdogtag2.RxDogTag;
/**
@@ -151,11 +154,13 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
initializeLogging();
Log.i(TAG, "onCreate()");
})
.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)
.addBlocking("mark-registration", () -> RegistrationUtil.maybeMarkRegistrationComplete())
@@ -165,7 +170,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("proxy-init", () -> {
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
Conscrypt.setUseEngineSocketByDefault(true);
ConscryptSignal.setUseEngineSocketByDefault(true);
}
})
.addBlocking("blob-provider", this::initializeBlobProvider)
@@ -227,6 +232,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
ExternalLaunchDonationJob.enqueueIfNecessary();
FcmFetchManager.onForeground(this);
startAnrDetector();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
@@ -260,6 +266,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getShakeToReport().disable();
ApplicationDependencies.getDeadlockDetector().stop();
MemoryTracker.stop();
AnrDetector.stop();
}
public void checkBuildExpiration() {
@@ -269,6 +276,17 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
}
}
/**
* Note: this is purposefully "started" twice -- once during application create, and once during foreground.
* 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) -> {
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
return Unit.INSTANCE;
});
}
private void initializeSecurityProvider() {
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
@@ -278,7 +296,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
throw new ProviderInitializationException();
}
int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2);
int conscryptPosition = Security.insertProviderAt(ConscryptSignal.newProvider(), 2);
Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition);
if (conscryptPosition < 0) {
@@ -410,6 +428,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
if (FeatureFlags.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);

View File

@@ -50,6 +50,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.pnikosis.materialishprogress.ProgressWheel;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.RxExtensions;
import org.signal.core.util.concurrent.SimpleTask;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
@@ -70,6 +71,8 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.groups.SelectionLimits;
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
@@ -682,11 +685,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
return UsernameUtil.fetchAciForUsername(username);
}, uuid -> {
try {
return RxExtensions.safeBlockingGet(UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username)));
} catch (InterruptedException e) {
Log.w(TAG, "Interrupted?", e);
return UsernameAciFetchResult.NetworkError.INSTANCE;
}
}, result -> {
loadingDialog.dismiss();
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
// TODO Could be more specific with errors
if (result instanceof UsernameAciFetchResult.Success success) {
Recipient recipient = Recipient.externalUsername(success.getAci(), username);
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
if (onContactSelectedListener != null) {

View File

@@ -195,7 +195,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
ProfileKey profileKey = ProfileKeyUtil.getSelfProfileKey();
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, verificationCode);
accountManager.addDevice(ephemeralId, publicKey, aciIdentityKeyPair, pniIdentityKeyPair, profileKey, SignalStore.svr().getOrCreateMasterKey(), verificationCode);
return SUCCESS;
} catch (NotFoundException e) {

View File

@@ -17,8 +17,11 @@ import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.logging.Log;
import org.signal.donations.StripeApi;
import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment;
import org.thoughtcrime.securesms.components.PromptBatterySaverDialogFragment;
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
@@ -89,10 +92,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
ConversationListTabRepository repository = new ConversationListTabRepository();
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
handleGroupLinkInIntent(getIntent());
handleProxyInIntent(getIntent());
handleSignalMeIntent(getIntent());
handleCallLinkInIntent(getIntent());
handleDeeplinkIntent(getIntent());
CachedInflater.from(this).clear();
@@ -134,10 +134,7 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
handleGroupLinkInIntent(intent);
handleProxyInIntent(intent);
handleSignalMeIntent(intent);
handleCallLinkInIntent(intent);
handleDeeplinkIntent(intent);
}
@Override
@@ -203,6 +200,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
return navigator;
}
private void handleDeeplinkIntent(Intent intent) {
handleGroupLinkInIntent(intent);
handleProxyInIntent(intent);
handleSignalMeIntent(intent);
handleCallLinkInIntent(intent);
handleDonateReturnIntent(intent);
}
private void handleGroupLinkInIntent(Intent intent) {
Uri data = intent.getData();
if (data != null) {
@@ -231,6 +236,13 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
}
}
private void handleDonateReturnIntent(Intent intent) {
Uri data = intent.getData();
if (data != null && data.toString().startsWith(StripeApi.RETURN_URL_IDEAL)) {
startActivity(AppSettingsActivity.manageSubscriptions(this));
}
}
public void onFirstRender() {
onFirstRender = true;
}

View File

@@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import java.io.IOException;
@@ -313,7 +312,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
if (!FeatureFlags.hideContacts() || recipient.isSelf() || recipient.isGroup()) {
if (recipient.isSelf() || recipient.isGroup()) {
return null;
}

View File

@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
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;
@@ -228,7 +228,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getCreateProfileNameIntent() {
Intent intent = EditProfileActivity.getIntentForUserProfile(this);
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
return getRoutedIntent(intent, getIntent());
}

View File

@@ -29,6 +29,7 @@ import android.media.AudioManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Rational;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
@@ -53,8 +54,8 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.core.util.ThreadUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
@@ -73,6 +74,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
@@ -152,6 +154,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private PictureInPictureParams.Builder pipBuilderParams;
private LifecycleDisposable lifecycleDisposable;
private long lastCallLinkDisconnectDialogShowTime;
private ControlsAndInfoController controlsAndInfo;
private Disposable ephemeralStateDisposable = Disposable.empty();
@@ -161,7 +164,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase);
}
@SuppressLint("SourceLockedOrientationActivity")
@SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
@@ -189,6 +192,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
initializeViewModel(isLandscapeEnabled);
initializePictureInPictureParams();
controlsAndInfo = new ControlsAndInfoController(callScreen, viewModel);
controlsAndInfo.addVisibilityListener(new FadeCallback());
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.webrtc_call_view_toolbar_text), findViewById(R.id.webrtc_call_view_toolbar_no_text));
lifecycleDisposable.add(controlsAndInfo);
logIntent(getIntent());
if (ANSWER_VIDEO_ACTION.equals(getIntent().getAction())) {
@@ -431,7 +441,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
viewModel.getWebRtcControls().observe(this, controls -> {
callScreen.setWebRtcControls(controls);
controlsAndInfo.updateControls(controls);
});
viewModel.getEvents().observe(this, this::handleViewModelEvent);
lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus));
@@ -522,7 +535,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video)
.setOnDismissListener(() -> viewModel.onDismissedVideoTooltip())
.show(TooltipPopup.POSITION_ABOVE);
return;
}
} else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) {
if (videoTooltip != null) {
@@ -912,7 +924,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
if (event.getGroupState().isNotIdle()) {
callScreen.setStatusFromGroupCallState(event.getGroupState());
callScreen.setRingGroup(event.shouldRingGroup());
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
@@ -946,10 +957,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
@Override
public void onControlsFadeOut() {
if (videoTooltip != null) {
videoTooltip.dismiss();
}
public void toggleControls() {
controlsAndInfo.toggleControls();
}
@Override
@@ -1060,7 +1069,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
if (liveRecipient.get().isCallLink()) {
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
} else {
CallParticipantsListDialog.show(getSupportFragmentManager());
controlsAndInfo.showCallInfo();
}
}
@@ -1124,4 +1133,20 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
}
private class FadeCallback implements ControlsAndInfoController.BottomSheetVisibilityListener {
@Override
public void onShown() {
fullscreenHelper.showSystemUI();
}
@Override
public void onHidden() {
fullscreenHelper.hideSystemUI();
if (videoTooltip != null) {
videoTooltip.dismiss();
}
}
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.animation;
import android.graphics.Point;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
@@ -16,6 +17,10 @@ public class ResizeAnimation extends Animation {
private int startWidth;
private int startHeight;
public ResizeAnimation(@NonNull View target, @NonNull Point dimension) {
this(target, dimension.x, dimension.y);
}
public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) {
this.target = target;
this.targetWidthPx = targetWidthPx;

View File

@@ -15,7 +15,9 @@ import org.signal.core.util.StreamUtil
import org.signal.core.util.getDownloadManager
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FileUtils
import java.io.FileInputStream
import java.io.IOException
@@ -37,7 +39,9 @@ object ApkUpdateInstaller {
*/
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! We likely have newer data. Ignoring.")
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)
ApplicationDependencies.getJobManager().add(ApkUpdateJob())
return
}
@@ -86,17 +90,6 @@ object ApkUpdateInstaller {
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
Log.d(TAG, "Clearing inactive sessions...")
packageInstaller.mySessions
.filter { session -> !session.isActive }
.forEach { session ->
try {
packageInstaller.abandonSession(session.sessionId)
} catch (e: SecurityException) {
Log.w(TAG, "Failed to abandon inactive session!", e)
}
}
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
// This lets us skip the system-generated notification.
@@ -151,8 +144,7 @@ object ApkUpdateInstaller {
}
private fun shouldAutoUpdate(): Boolean {
// TODO Auto-updates temporarily disabled. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
return false
// return Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
// 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 && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
}
}

View File

@@ -30,6 +30,9 @@ object ApkUpdateNotifications {
*/
@SuppressLint("LaunchActivityFromNotification")
fun showInstallPrompt(context: Context, downloadId: Long) {
Log.d(TAG, "Showing install prompt. DownloadId: $downloadId")
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_FAILED_INSTALL)
val pendingIntent = PendingIntent.getBroadcast(
context,
1,
@@ -37,7 +40,7 @@ object ApkUpdateNotifications {
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
},
PendingIntentFlags.immutable()
PendingIntentFlags.updateCurrent()
)
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
@@ -52,7 +55,15 @@ object ApkUpdateNotifications {
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
}
fun dismissInstallPrompt(context: Context) {
Log.d(TAG, "Dismissing install prompt.")
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
}
fun showInstallFailed(context: Context, reason: FailureReason) {
Log.d(TAG, "Showing failed notification. Reason: $reason")
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
val pendingIntent = PendingIntent.getActivity(
context,
0,
@@ -66,11 +77,34 @@ object ApkUpdateNotifications {
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
}
fun showAutoUpdateSuccess(context: Context) {
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntentFlags.immutable()
)
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_auto_update_success_title))
.setContentText(context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName))
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_SUCCESSFUL_INSTALL, notification)
}
enum class FailureReason {
UNKNOWN,
ABORTED,

View File

@@ -12,6 +12,7 @@ import android.content.pm.PackageInstaller
import org.signal.core.util.getParcelableExtraCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.apkupdate.ApkUpdateNotifications.FailureReason
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* This is the receiver that is triggered by the [PackageInstaller] to notify of various events. Package installation is initiated
@@ -34,8 +35,16 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
Log.w(TAG, "[onReceive] Status: $statusCode, Message: $statusMessage")
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
ApkUpdateNotifications.showAutoUpdateSuccess(context)
} else {
Log.i(TAG, "Spurious 'success' notification?")
}
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!)
PackageInstaller.STATUS_SUCCESS -> Log.w(TAG, "Update installed successfully!")
PackageInstaller.STATUS_FAILURE_ABORTED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.ABORTED)
PackageInstaller.STATUS_FAILURE_BLOCKED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.BLOCKED)
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INCOMPATIBLE)

View File

@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.ApkUpdateJob;
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.concurrent.TimeUnit;
@@ -21,7 +22,7 @@ public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener {
private static final String TAG = Log.tag(ApkUpdateRefreshListener.class);
private static final long INTERVAL = TimeUnit.HOURS.toMillis(6);
private static final long INTERVAL = Environment.IS_NIGHTLY ? TimeUnit.HOURS.toMillis(2) : TimeUnit.HOURS.toMillis(6);
@Override
protected long getNextScheduledExecutionTime(Context context) {

View File

@@ -113,6 +113,23 @@ public class AudioRecorder {
});
}
public void discardRecording() {
Log.i(TAG, "cancelRecording()");
executor.execute(() -> {
if (recorder == null) {
Log.e(TAG, "MediaRecorder was never initialized successfully!");
return;
}
audioFocusManager.abandonAudioFocus();
recorder.stop();
recordingUriFuture.cancel(true);
recordingSubject = null;
recorder = null;
recordingUriFuture = null;
});
}
public void stopRecording() {
Log.i(TAG, "stopRecording()");

View File

@@ -0,0 +1,165 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.EventTimer
import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.ChatItemBackupProcessor
import org.thoughtcrime.securesms.backup.v2.processor.RecipientBackupProcessor
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.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.io.ByteArrayOutputStream
import java.io.InputStream
object BackupRepository {
private val TAG = Log.tag(BackupRepository::class.java)
fun export(plaintext: Boolean = false): ByteArray {
val eventTimer = EventTimer()
val outputStream = ByteArrayOutputStream()
val writer: BackupExportWriter = if (plaintext) {
PlainTextBackupWriter(outputStream)
} else {
EncryptedBackupWriter(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = SignalStore.account().aci!!,
outputStream = outputStream,
append = { mac -> outputStream.write(mac) }
)
}
writer.use {
// 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 {
writer.write(it)
eventTimer.emit("recipient")
}
ChatBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("thread")
}
ChatItemBackupProcessor.export { frame ->
writer.write(frame)
eventTimer.emit("message")
}
}
}
Log.d(TAG, "export() ${eventTimer.stop().summary}")
return outputStream.toByteArray()
}
fun import(length: Long, inputStreamFactory: () -> InputStream, selfData: SelfData, plaintext: Boolean = false) {
val eventTimer = EventTimer()
val frameReader = if (plaintext) {
PlainTextBackupReader(inputStreamFactory())
} else {
EncryptedBackupReader(
key = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey(),
aci = selfData.aci,
streamLength = length,
dataStream = inputStreamFactory
)
}
// Note: Without a transaction, bad imports could lead to lost data. But because we have a transaction,
// writes from other threads are blocked. This is something to think more about.
SignalDatabase.rawDatabase.withinTransaction {
SignalStore.clearAllDataForBackupRestore()
SignalDatabase.recipients.clearAllDataForBackupRestore()
SignalDatabase.distributionLists.clearAllDataForBackupRestore()
SignalDatabase.threads.clearAllDataForBackupRestore()
SignalDatabase.messages.clearAllDataForBackupRestore()
SignalDatabase.attachments.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)
val backupState = BackupState()
val chatItemInserter: ChatItemImportInserter = ChatItemBackupProcessor.beginImport(backupState)
for (frame in frameReader) {
when {
frame.account != null -> {
AccountDataProcessor.import(frame.account, selfId)
eventTimer.emit("account")
}
frame.recipient != null -> {
RecipientBackupProcessor.import(frame.recipient, backupState)
eventTimer.emit("recipient")
}
frame.chat != null -> {
ChatBackupProcessor.import(frame.chat, backupState)
eventTimer.emit("chat")
}
frame.chatItem != null -> {
chatItemInserter.insert(frame.chatItem)
eventTimer.emit("chatItem")
// TODO if there's stuff in the stream after chatItems, we need to flush the inserter before going to the next phase
}
else -> Log.w(TAG, "Unrecognized frame")
}
}
if (chatItemInserter.flush()) {
eventTimer.emit("chatItem")
}
backupState.chatIdToLocalThreadId.values.forEach {
SignalDatabase.threads.update(it, unarchive = false, allowDeletion = false)
}
}
Log.d(TAG, "import() ${eventTimer.stop().summary}")
}
data class SelfData(
val aci: ACI,
val pni: PNI,
val e164: String,
val profileKey: ProfileKey
)
}
class BackupState {
val backupToLocalRecipientId = HashMap<Long, RecipientId>()
val chatIdToLocalThreadId = HashMap<Long, Long>()
val chatIdToLocalRecipientId = HashMap<Long, RecipientId>()
val chatIdToBackupRecipientId = HashMap<Long, Long>()
}

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.delete
import org.thoughtcrime.securesms.database.AttachmentTable
fun AttachmentTable.clearAllDataForBackupRestore() {
writableDatabase.delete(AttachmentTable.TABLE_NAME).run()
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
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.requireString
import org.thoughtcrime.securesms.backup.v2.proto.ChatItem
import org.thoughtcrime.securesms.backup.v2.proto.ChatUpdateMessage
import org.thoughtcrime.securesms.backup.v2.proto.ExpirationTimerChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.ProfileChangeChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.RemoteDeletedMessage
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.Text
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import java.io.Closeable
import java.io.IOException
import java.util.LinkedList
import java.util.Queue
import org.thoughtcrime.securesms.backup.v2.proto.BodyRange as BackupBodyRange
/**
* An iterator for chat items with a clever performance twist: rather than do the extra queries one at a time (for reactions,
* attachments, etc), this will populate items in batches, doing bulk lookups to improve throughput. We keep these in a buffer
* and only do more queries when the buffer is empty.
*
* All of this complexity is hidden from the user -- they just get a normal iterator interface.
*/
class ChatItemExportIterator(private val cursor: Cursor, private val batchSize: Int) : Iterator<ChatItem>, Closeable {
companion object {
private val TAG = Log.tag(ChatItemExportIterator::class.java)
const val COLUMN_BASE_TYPE = "base_type"
}
/**
* A queue of already-parsed ChatItems. Processing in batches means that we read ahead in the cursor and put
* the pending items here.
*/
private val buffer: Queue<ChatItem> = LinkedList()
override fun hasNext(): Boolean {
return buffer.isNotEmpty() || (cursor.count > 0 && !cursor.isLast && !cursor.isAfterLast)
}
override fun next(): ChatItem {
if (buffer.isNotEmpty()) {
return buffer.remove()
}
val records: LinkedHashMap<Long, BackupMessageRecord> = linkedMapOf()
for (i in 0 until batchSize) {
if (cursor.moveToNext()) {
val record = cursor.toBackupMessageRecord()
records[record.id] = record
} else {
break
}
}
val reactionsById: Map<Long, List<ReactionRecord>> = SignalDatabase.reactions.getReactionsForMessages(records.keys)
val groupReceiptsById: Map<Long, List<GroupReceiptTable.GroupReceiptInfo>> = SignalDatabase.groupReceipts.getGroupReceiptInfoForMessages(records.keys)
for ((id, record) in records) {
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))
MessageTypes.isChangeNumber(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.CHANGE_NUMBER))
MessageTypes.isBoostRequest(record.type) -> builder.updateMessage = ChatUpdateMessage(simpleUpdate = SimpleChatUpdate(type = SimpleChatUpdate.Type.BOOST_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 / 1000).toInt()))
MessageTypes.isProfileChange(record.type) -> {
builder.updateMessage = ChatUpdateMessage(
profileChange = try {
val decoded: ByteArray = Base64.decode(record.body!!)
val profileChangeDetails = ProfileChangeDetails.ADAPTER.decode(decoded)
if (profileChangeDetails.profileNameChange != null) {
ProfileChangeChatUpdate(previousName = profileChangeDetails.profileNameChange.previous, newName = profileChangeDetails.profileNameChange.newValue)
} else {
ProfileChangeChatUpdate()
}
} catch (e: IOException) {
Log.w(TAG, "Profile name change details could not be read", e)
ProfileChangeChatUpdate()
}
)
}
else -> builder.standardMessage = record.toTextMessage(reactionsById[id])
}
buffer += builder.build()
}
return if (buffer.isNotEmpty()) {
buffer.remove()
} else {
throw NoSuchElementException()
}
}
override fun close() {
cursor.close()
}
private fun BackupMessageRecord.toBasicChatItemBuilder(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): ChatItem.Builder {
val record = this
return ChatItem.Builder().apply {
chatId = record.threadId
authorId = record.fromRecipientId
dateSent = record.dateSent
sealedSender = record.sealedSender
expireStartDate = if (record.expireStarted > 0) record.expireStarted else null
expiresInMs = if (record.expiresIn > 0) record.expiresIn else null
revisions = emptyList()
sms = !MessageTypes.isSecureType(record.type)
if (MessageTypes.isOutgoingMessageType(record.type)) {
outgoing = ChatItem.OutgoingMessageDetails(
sendStatus = record.toBackupSendStatus(groupReceipts)
)
} else {
incoming = ChatItem.IncomingMessageDetails(
dateServerSent = record.dateServer,
dateReceived = record.dateReceived,
read = record.read
)
}
}
}
private fun BackupMessageRecord.toTextMessage(reactionRecords: List<ReactionRecord>?): StandardMessage {
return StandardMessage(
quote = this.toQuote(),
text = Text(
body = this.body!!,
bodyRanges = this.bodyRanges?.toBackupBodyRanges() ?: emptyList()
),
// TODO Link previews!
linkPreview = emptyList(),
longText = null,
reactions = reactionRecords.toBackupReactions()
)
}
private fun BackupMessageRecord.toQuote(): Quote? {
return if (this.quoteTargetSentTimestamp != MessageTable.QUOTE_NOT_PRESENT_ID && this.quoteAuthor > 0) {
// TODO Attachments!
val type = QuoteModel.Type.fromCode(this.quoteType)
Quote(
targetSentTimestamp = this.quoteTargetSentTimestamp.takeIf { !this.quoteMissing && it != MessageTable.QUOTE_TARGET_MISSING_ID },
authorId = this.quoteAuthor,
text = this.quoteBody,
bodyRanges = this.quoteBodyRanges?.toBackupBodyRanges() ?: emptyList(),
type = when (type) {
QuoteModel.Type.NORMAL -> Quote.Type.NORMAL
QuoteModel.Type.GIFT_BADGE -> Quote.Type.GIFTBADGE
}
)
} else {
null
}
}
private fun ByteArray.toBackupBodyRanges(): List<BackupBodyRange> {
val decoded: BodyRangeList = try {
BodyRangeList.ADAPTER.decode(this)
} catch (e: IOException) {
Log.w(TAG, "Failed to decode BodyRangeList!")
return emptyList()
}
return decoded.ranges.map {
BackupBodyRange(
start = it.start,
length = it.length,
mentionAci = it.mentionUuid?.let { UuidUtil.parseOrThrow(it) }?.toByteArray()?.toByteString(),
style = it.style?.toBackupBodyRangeStyle()
)
}
}
private fun BodyRangeList.BodyRange.Style.toBackupBodyRangeStyle(): BackupBodyRange.Style {
return when (this) {
BodyRangeList.BodyRange.Style.BOLD -> BackupBodyRange.Style.BOLD
BodyRangeList.BodyRange.Style.ITALIC -> BackupBodyRange.Style.ITALIC
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BackupBodyRange.Style.STRIKETHROUGH
BodyRangeList.BodyRange.Style.MONOSPACE -> BackupBodyRange.Style.MONOSPACE
BodyRangeList.BodyRange.Style.SPOILER -> BackupBodyRange.Style.SPOILER
}
}
private fun List<ReactionRecord>?.toBackupReactions(): List<Reaction> {
return this
?.map {
Reaction(
emoji = it.emoji,
authorId = it.author.toLong(),
sentTimestamp = it.dateSent,
receivedTimestamp = it.dateReceived
)
} ?: emptyList()
}
private fun BackupMessageRecord.toBackupSendStatus(groupReceipts: List<GroupReceiptTable.GroupReceiptInfo>?): List<SendStatus> {
if (!MessageTypes.isOutgoingMessageType(this.type)) {
return emptyList()
}
if (!groupReceipts.isNullOrEmpty()) {
return groupReceipts.toBackupSendStatus(this.networkFailureRecipientIds, this.identityMismatchRecipientIds)
}
val status: SendStatus.Status = when {
this.viewed -> SendStatus.Status.VIEWED
this.hasReadReceipt -> SendStatus.Status.READ
this.hasDeliveryReceipt -> SendStatus.Status.DELIVERED
this.baseType == MessageTypes.BASE_SENT_TYPE -> SendStatus.Status.SENT
MessageTypes.isFailedMessageType(this.type) -> SendStatus.Status.FAILED
else -> SendStatus.Status.PENDING
}
return listOf(
SendStatus(
recipientId = this.toRecipientId,
deliveryStatus = status,
lastStatusUpdateTimestamp = this.receiptTimestamp,
sealedSender = this.sealedSender,
networkFailure = this.networkFailureRecipientIds.contains(this.toRecipientId),
identityKeyMismatch = this.identityMismatchRecipientIds.contains(this.toRecipientId)
)
)
}
private fun List<GroupReceiptTable.GroupReceiptInfo>.toBackupSendStatus(networkFailureRecipientIds: Set<Long>, identityMismatchRecipientIds: Set<Long>): List<SendStatus> {
return this.map {
SendStatus(
recipientId = it.recipientId.toLong(),
deliveryStatus = it.status.toBackupDeliveryStatus(),
sealedSender = it.isUnidentified,
lastStatusUpdateTimestamp = it.timestamp,
networkFailure = networkFailureRecipientIds.contains(it.recipientId.toLong()),
identityKeyMismatch = identityMismatchRecipientIds.contains(it.recipientId.toLong())
)
}
}
private fun Int.toBackupDeliveryStatus(): SendStatus.Status {
return when (this) {
GroupReceiptTable.STATUS_UNDELIVERED -> SendStatus.Status.PENDING
GroupReceiptTable.STATUS_DELIVERED -> SendStatus.Status.DELIVERED
GroupReceiptTable.STATUS_READ -> SendStatus.Status.READ
GroupReceiptTable.STATUS_VIEWED -> SendStatus.Status.VIEWED
GroupReceiptTable.STATUS_SKIPPED -> SendStatus.Status.SKIPPED
else -> SendStatus.Status.SKIPPED
}
}
private fun String?.parseNetworkFailures(): Set<Long> {
if (this.isNullOrBlank()) {
return emptySet()
}
return try {
JsonUtils.fromJson(this, NetworkFailureSet::class.java).items.map { it.recipientId.toLong() }.toSet()
} catch (e: IOException) {
emptySet()
}
}
private fun String?.parseIdentityMismatches(): Set<Long> {
if (this.isNullOrBlank()) {
return emptySet()
}
return try {
JsonUtils.fromJson(this, IdentityKeyMismatchSet::class.java).items.map { it.recipientId.toLong() }.toSet()
} catch (e: IOException) {
emptySet()
}
}
private fun Cursor.toBackupMessageRecord(): BackupMessageRecord {
return BackupMessageRecord(
id = this.requireLong(MessageTable.ID),
dateSent = this.requireLong(MessageTable.DATE_SENT),
dateReceived = this.requireLong(MessageTable.DATE_RECEIVED),
dateServer = this.requireLong(MessageTable.DATE_SERVER),
type = this.requireLong(MessageTable.TYPE),
threadId = this.requireLong(MessageTable.THREAD_ID),
body = this.requireString(MessageTable.BODY),
bodyRanges = this.requireBlob(MessageTable.MESSAGE_RANGES),
fromRecipientId = this.requireLong(MessageTable.FROM_RECIPIENT_ID),
toRecipientId = this.requireLong(MessageTable.TO_RECIPIENT_ID),
expiresIn = this.requireLong(MessageTable.EXPIRES_IN),
expireStarted = this.requireLong(MessageTable.EXPIRE_STARTED),
remoteDeleted = this.requireBoolean(MessageTable.REMOTE_DELETED),
sealedSender = this.requireBoolean(MessageTable.UNIDENTIFIED),
quoteTargetSentTimestamp = this.requireLong(MessageTable.QUOTE_ID),
quoteAuthor = this.requireLong(MessageTable.QUOTE_AUTHOR),
quoteBody = this.requireString(MessageTable.QUOTE_BODY),
quoteMissing = this.requireBoolean(MessageTable.QUOTE_MISSING),
quoteBodyRanges = this.requireBlob(MessageTable.QUOTE_BODY_RANGES),
quoteType = this.requireInt(MessageTable.QUOTE_TYPE),
originalMessageId = this.requireLong(MessageTable.ORIGINAL_MESSAGE_ID),
latestRevisionId = this.requireLong(MessageTable.LATEST_REVISION_ID),
hasDeliveryReceipt = this.requireBoolean(MessageTable.HAS_DELIVERY_RECEIPT),
viewed = this.requireBoolean(MessageTable.VIEWED_COLUMN),
hasReadReceipt = this.requireBoolean(MessageTable.HAS_READ_RECEIPT),
read = this.requireBoolean(MessageTable.READ),
receiptTimestamp = this.requireLong(MessageTable.RECEIPT_TIMESTAMP),
networkFailureRecipientIds = this.requireString(MessageTable.NETWORK_FAILURES).parseNetworkFailures(),
identityMismatchRecipientIds = this.requireString(MessageTable.MISMATCHED_IDENTITIES).parseIdentityMismatches(),
baseType = this.requireLong(COLUMN_BASE_TYPE)
)
}
private class BackupMessageRecord(
val id: Long,
val dateSent: Long,
val dateReceived: Long,
val dateServer: Long,
val type: Long,
val threadId: Long,
val body: String?,
val bodyRanges: ByteArray?,
val fromRecipientId: Long,
val toRecipientId: Long,
val expiresIn: Long,
val expireStarted: Long,
val remoteDeleted: Boolean,
val sealedSender: Boolean,
val quoteTargetSentTimestamp: Long,
val quoteAuthor: Long,
val quoteBody: String?,
val quoteMissing: Boolean,
val quoteBodyRanges: ByteArray?,
val quoteType: Int,
val originalMessageId: Long,
val latestRevisionId: Long,
val hasDeliveryReceipt: Boolean,
val hasReadReceipt: Boolean,
val viewed: Boolean,
val receiptTimestamp: Long,
val read: Boolean,
val networkFailureRecipientIds: Set<Long>,
val identityMismatchRecipientIds: Set<Long>,
val baseType: Long
)
}

View File

@@ -0,0 +1,442 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.content.ContentValues
import androidx.core.content.contentValuesOf
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.toInt
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.Quote
import org.thoughtcrime.securesms.backup.v2.proto.Reaction
import org.thoughtcrime.securesms.backup.v2.proto.SendStatus
import org.thoughtcrime.securesms.backup.v2.proto.SimpleChatUpdate
import org.thoughtcrime.securesms.backup.v2.proto.StandardMessage
import org.thoughtcrime.securesms.database.GroupReceiptTable
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.ReactionTable
import org.thoughtcrime.securesms.database.SQLiteDatabase
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchSet
import org.thoughtcrime.securesms.database.documents.NetworkFailure
import org.thoughtcrime.securesms.database.documents.NetworkFailureSet
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.JsonUtils
import org.whispersystems.signalservice.api.util.UuidUtil
/**
* 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
* for fast throughput.
*/
class ChatItemImportInserter(
private val db: SQLiteDatabase,
private val backupState: BackupState,
private val batchSize: Int
) {
companion object {
private val TAG = Log.tag(ChatItemImportInserter::class.java)
private val MESSAGE_COLUMNS = arrayOf(
MessageTable.DATE_SENT,
MessageTable.DATE_RECEIVED,
MessageTable.DATE_SERVER,
MessageTable.TYPE,
MessageTable.THREAD_ID,
MessageTable.READ,
MessageTable.BODY,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.TO_RECIPIENT_ID,
MessageTable.HAS_DELIVERY_RECEIPT,
MessageTable.HAS_READ_RECEIPT,
MessageTable.VIEWED_COLUMN,
MessageTable.MISMATCHED_IDENTITIES,
MessageTable.EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.UNIDENTIFIED,
MessageTable.REMOTE_DELETED,
MessageTable.REMOTE_DELETED,
MessageTable.NETWORK_FAILURES,
MessageTable.QUOTE_ID,
MessageTable.QUOTE_AUTHOR,
MessageTable.QUOTE_BODY,
MessageTable.QUOTE_MISSING,
MessageTable.QUOTE_BODY_RANGES,
MessageTable.QUOTE_TYPE,
MessageTable.SHARED_CONTACTS,
MessageTable.LINK_PREVIEWS,
MessageTable.MESSAGE_RANGES,
MessageTable.VIEW_ONCE
)
private val REACTION_COLUMNS = arrayOf(
ReactionTable.MESSAGE_ID,
ReactionTable.AUTHOR_ID,
ReactionTable.EMOJI,
ReactionTable.DATE_SENT,
ReactionTable.DATE_RECEIVED
)
private val GROUP_RECEIPT_COLUMNS = arrayOf(
GroupReceiptTable.MMS_ID,
GroupReceiptTable.RECIPIENT_ID,
GroupReceiptTable.STATUS,
GroupReceiptTable.TIMESTAMP,
GroupReceiptTable.UNIDENTIFIED
)
}
private val selfId = Recipient.self().id
private val buffer: Buffer = Buffer()
private var messageId: Long = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME)
/**
* Indicate that you want to insert the [ChatItem] into the database.
* If this item causes the buffer to hit the batch size, then a batch of items will actually be inserted.
*/
fun insert(chatItem: ChatItem) {
val fromLocalRecipientId: RecipientId? = backupState.backupToLocalRecipientId[chatItem.authorId]
if (fromLocalRecipientId == null) {
Log.w(TAG, "[insert] Could not find a local recipient for backup recipient ID ${chatItem.authorId}! Skipping.")
return
}
val chatLocalRecipientId: RecipientId? = backupState.chatIdToLocalRecipientId[chatItem.chatId]
if (chatLocalRecipientId == null) {
Log.w(TAG, "[insert] Could not find a local recipient for chatId ${chatItem.chatId}! Skipping.")
return
}
val localThreadId: Long? = backupState.chatIdToLocalThreadId[chatItem.chatId]
if (localThreadId == null) {
Log.w(TAG, "[insert] Could not find a local threadId for backup chatId ${chatItem.chatId}! Skipping.")
return
}
val chatBackupRecipientId: Long? = backupState.chatIdToBackupRecipientId[chatItem.chatId]
if (chatBackupRecipientId == null) {
Log.w(TAG, "[insert] Could not find a backup recipientId for backup chatId ${chatItem.chatId}! Skipping.")
return
}
buffer.messages += chatItem.toMessageContentValues(fromLocalRecipientId, chatLocalRecipientId, localThreadId)
buffer.reactions += chatItem.toReactionContentValues(messageId)
buffer.groupReceipts += chatItem.toGroupReceiptContentValues(messageId, chatBackupRecipientId)
messageId++
if (buffer.size >= batchSize) {
flush()
}
}
/** Returns true if something was written to the db, otherwise false. */
fun flush(): Boolean {
if (buffer.size == 0) {
return false
}
SqlUtil.buildBulkInsert(MessageTable.TABLE_NAME, MESSAGE_COLUMNS, buffer.messages).forEach {
db.execSQL(it.where, it.whereArgs)
}
SqlUtil.buildBulkInsert(ReactionTable.TABLE_NAME, REACTION_COLUMNS, buffer.reactions).forEach {
db.execSQL(it.where, it.whereArgs)
}
SqlUtil.buildBulkInsert(GroupReceiptTable.TABLE_NAME, GROUP_RECEIPT_COLUMNS, buffer.groupReceipts).forEach {
db.execSQL(it.where, it.whereArgs)
}
messageId = SqlUtil.getNextAutoIncrementId(db, MessageTable.TABLE_NAME)
return true
}
private fun ChatItem.toMessageContentValues(fromRecipientId: RecipientId, chatRecipientId: RecipientId, threadId: Long): ContentValues {
val contentValues = ContentValues()
contentValues.put(MessageTable.TYPE, this.getMessageType())
contentValues.put(MessageTable.DATE_SENT, this.dateSent)
contentValues.put(MessageTable.DATE_SERVER, this.incoming?.dateServerSent ?: -1)
contentValues.put(MessageTable.FROM_RECIPIENT_ID, fromRecipientId.serialize())
contentValues.put(MessageTable.TO_RECIPIENT_ID, (if (this.outgoing != null) chatRecipientId else selfId).serialize())
contentValues.put(MessageTable.THREAD_ID, threadId)
contentValues.put(MessageTable.DATE_RECEIVED, this.incoming?.dateReceived ?: this.dateSent)
contentValues.put(MessageTable.RECEIPT_TIMESTAMP, this.outgoing?.sendStatus?.maxOf { it.lastStatusUpdateTimestamp } ?: 0)
contentValues.putNull(MessageTable.LATEST_REVISION_ID)
contentValues.putNull(MessageTable.ORIGINAL_MESSAGE_ID)
contentValues.put(MessageTable.REVISION_NUMBER, 0)
contentValues.put(MessageTable.EXPIRES_IN, this.expiresInMs ?: 0)
contentValues.put(MessageTable.EXPIRE_STARTED, this.expireStartDate ?: 0)
if (this.outgoing != null) {
val viewed = this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.VIEWED }
val hasReadReceipt = viewed || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.READ }
val hasDeliveryReceipt = viewed || hasReadReceipt || this.outgoing.sendStatus.any { it.deliveryStatus == SendStatus.Status.DELIVERED }
contentValues.put(MessageTable.VIEWED_COLUMN, viewed.toInt())
contentValues.put(MessageTable.HAS_READ_RECEIPT, hasReadReceipt.toInt())
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, hasDeliveryReceipt.toInt())
contentValues.put(MessageTable.UNIDENTIFIED, this.outgoing.sendStatus.count { it.sealedSender })
contentValues.put(MessageTable.READ, 1)
contentValues.addNetworkFailures(this, backupState)
contentValues.addIdentityKeyMismatches(this, backupState)
} else {
contentValues.put(MessageTable.VIEWED_COLUMN, 0)
contentValues.put(MessageTable.HAS_READ_RECEIPT, 0)
contentValues.put(MessageTable.HAS_DELIVERY_RECEIPT, 0)
contentValues.put(MessageTable.UNIDENTIFIED, this.sealedSender?.toInt())
contentValues.put(MessageTable.READ, this.incoming?.read?.toInt() ?: 0)
}
contentValues.put(MessageTable.QUOTE_ID, 0)
contentValues.put(MessageTable.QUOTE_AUTHOR, 0)
contentValues.put(MessageTable.QUOTE_MISSING, 0)
contentValues.put(MessageTable.QUOTE_TYPE, 0)
contentValues.put(MessageTable.VIEW_ONCE, 0)
contentValues.put(MessageTable.REMOTE_DELETED, 0)
when {
this.standardMessage != null -> contentValues.addStandardMessage(this.standardMessage)
this.remoteDeletedMessage != null -> contentValues.put(MessageTable.REMOTE_DELETED, 1)
this.updateMessage != null -> contentValues.addUpdateMessage(this.updateMessage)
}
return contentValues
}
private fun ChatItem.toReactionContentValues(messageId: Long): List<ContentValues> {
val reactions: List<Reaction> = when {
this.standardMessage != null -> this.standardMessage.reactions
this.contactMessage != null -> this.contactMessage.reactions
this.voiceMessage != null -> this.voiceMessage.reactions
this.stickerMessage != null -> this.stickerMessage.reactions
else -> emptyList()
}
return reactions
.mapNotNull {
val authorId: Long? = backupState.backupToLocalRecipientId[it.authorId]?.toLong()
if (authorId != null) {
contentValuesOf(
ReactionTable.MESSAGE_ID to messageId,
ReactionTable.AUTHOR_ID to authorId,
ReactionTable.DATE_SENT to it.sentTimestamp,
ReactionTable.DATE_RECEIVED to it.receivedTimestamp,
ReactionTable.EMOJI to it.emoji
)
} else {
Log.w(TAG, "[Reaction] Could not find a local recipient for backup recipient ID ${it.authorId}! Skipping.")
null
}
}
}
private fun ChatItem.toGroupReceiptContentValues(messageId: Long, chatBackupRecipientId: Long): List<ContentValues> {
if (this.outgoing == null) {
return emptyList()
}
// TODO This seems like an indirect/bad way to detect if this is a 1:1 or group convo
if (this.outgoing.sendStatus.size == 1 && this.outgoing.sendStatus[0].recipientId == chatBackupRecipientId) {
return emptyList()
}
return this.outgoing.sendStatus.mapNotNull { sendStatus ->
val recipientId = backupState.backupToLocalRecipientId[sendStatus.recipientId]
if (recipientId != null) {
contentValuesOf(
GroupReceiptTable.MMS_ID to messageId,
GroupReceiptTable.RECIPIENT_ID to recipientId.serialize(),
GroupReceiptTable.STATUS to sendStatus.deliveryStatus.toLocalSendStatus(),
GroupReceiptTable.TIMESTAMP to sendStatus.lastStatusUpdateTimestamp,
GroupReceiptTable.UNIDENTIFIED to sendStatus.sealedSender
)
} else {
Log.w(TAG, "[GroupReceipts] Could not find a local recipient for backup recipient ID ${sendStatus.recipientId}! Skipping.")
null
}
}
}
private fun ChatItem.getMessageType(): Long {
var type: Long = if (this.outgoing != null) {
if (this.outgoing.sendStatus.count { it.identityKeyMismatch } > 0) {
MessageTypes.BASE_SENT_FAILED_TYPE
} else if (this.outgoing.sendStatus.count { it.networkFailure } > 0) {
MessageTypes.BASE_SENDING_TYPE
} else {
MessageTypes.BASE_SENT_TYPE
}
} else {
MessageTypes.BASE_INBOX_TYPE
}
if (!this.sms) {
type = type or MessageTypes.SECURE_MESSAGE_BIT or MessageTypes.PUSH_MESSAGE_BIT
}
return type
}
private fun ContentValues.addStandardMessage(standardMessage: StandardMessage) {
if (standardMessage.text != null) {
this.put(MessageTable.BODY, standardMessage.text.body)
if (standardMessage.text.bodyRanges.isNotEmpty()) {
this.put(MessageTable.MESSAGE_RANGES, standardMessage.text.bodyRanges.toLocalBodyRanges()?.encode() as ByteArray?)
}
}
if (standardMessage.quote != null) {
this.addQuote(standardMessage.quote)
}
}
private fun ContentValues.addUpdateMessage(updateMessage: ChatUpdateMessage) {
var typeFlags: Long = 0
when {
updateMessage.simpleUpdate != null -> {
typeFlags = when (updateMessage.simpleUpdate.type) {
SimpleChatUpdate.Type.UNKNOWN -> 0
SimpleChatUpdate.Type.JOINED_SIGNAL -> MessageTypes.JOINED_TYPE
SimpleChatUpdate.Type.IDENTITY_UPDATE -> MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT
SimpleChatUpdate.Type.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT
SimpleChatUpdate.Type.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT
SimpleChatUpdate.Type.CHANGE_NUMBER -> MessageTypes.CHANGE_NUMBER_TYPE
SimpleChatUpdate.Type.BOOST_REQUEST -> MessageTypes.BOOST_REQUEST_TYPE
SimpleChatUpdate.Type.END_SESSION -> MessageTypes.END_SESSION_BIT
SimpleChatUpdate.Type.CHAT_SESSION_REFRESH -> MessageTypes.ENCRYPTION_REMOTE_FAILED_BIT
SimpleChatUpdate.Type.BAD_DECRYPT -> MessageTypes.BAD_DECRYPT_TYPE
SimpleChatUpdate.Type.PAYMENTS_ACTIVATED -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST -> MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
}
}
updateMessage.expirationTimerChange != null -> {
typeFlags = MessageTypes.EXPIRATION_TIMER_UPDATE_BIT
put(MessageTable.EXPIRES_IN, updateMessage.expirationTimerChange.expiresInMs.toLong())
}
updateMessage.profileChange != null -> {
typeFlags = MessageTypes.PROFILE_CHANGE_TYPE
val profileChangeDetails = ProfileChangeDetails(profileNameChange = ProfileChangeDetails.StringChange(previous = updateMessage.profileChange.previousName, newValue = updateMessage.profileChange.newName))
.encode()
put(MessageTable.BODY, Base64.encodeWithPadding(profileChangeDetails))
}
}
this.put(MessageTable.TYPE, getAsLong(MessageTable.TYPE) or typeFlags)
}
private fun ContentValues.addQuote(quote: Quote) {
this.put(MessageTable.QUOTE_ID, quote.targetSentTimestamp ?: MessageTable.QUOTE_TARGET_MISSING_ID)
this.put(MessageTable.QUOTE_AUTHOR, backupState.backupToLocalRecipientId[quote.authorId]!!.serialize())
this.put(MessageTable.QUOTE_BODY, quote.text)
this.put(MessageTable.QUOTE_TYPE, quote.type.toLocalQuoteType())
this.put(MessageTable.QUOTE_BODY_RANGES, quote.bodyRanges.toLocalBodyRanges()?.encode())
// TODO quote attachments
this.put(MessageTable.QUOTE_MISSING, (quote.targetSentTimestamp == null).toInt())
}
private fun Quote.Type.toLocalQuoteType(): Int {
return when (this) {
Quote.Type.UNKNOWN -> QuoteModel.Type.NORMAL.code
Quote.Type.NORMAL -> QuoteModel.Type.NORMAL.code
Quote.Type.GIFTBADGE -> QuoteModel.Type.GIFT_BADGE.code
}
}
private fun ContentValues.addNetworkFailures(chatItem: ChatItem, backupState: BackupState) {
if (chatItem.outgoing == null) {
return
}
val networkFailures = chatItem.outgoing.sendStatus
.filter { status -> status.networkFailure }
.mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] }
.map { recipientId -> NetworkFailure(recipientId) }
.toSet()
if (networkFailures.isNotEmpty()) {
this.put(MessageTable.NETWORK_FAILURES, JsonUtils.toJson(NetworkFailureSet(networkFailures)))
}
}
private fun ContentValues.addIdentityKeyMismatches(chatItem: ChatItem, backupState: BackupState) {
if (chatItem.outgoing == null) {
return
}
val mismatches = chatItem.outgoing.sendStatus
.filter { status -> status.identityKeyMismatch }
.mapNotNull { status -> backupState.backupToLocalRecipientId[status.recipientId] }
.map { recipientId -> IdentityKeyMismatch(recipientId, null) } // TODO We probably want the actual identity key in this status situation?
.toSet()
if (mismatches.isNotEmpty()) {
this.put(MessageTable.MISMATCHED_IDENTITIES, JsonUtils.toJson(IdentityKeyMismatchSet(mismatches)))
}
}
private fun List<BodyRange>.toLocalBodyRanges(): BodyRangeList? {
if (this.isEmpty()) {
return null
}
return BodyRangeList(
ranges = this.map { bodyRange ->
BodyRangeList.BodyRange(
mentionUuid = bodyRange.mentionAci?.let { UuidUtil.fromByteString(it) }?.toString(),
style = bodyRange.style?.let {
when (bodyRange.style) {
BodyRange.Style.BOLD -> BodyRangeList.BodyRange.Style.BOLD
BodyRange.Style.ITALIC -> BodyRangeList.BodyRange.Style.ITALIC
BodyRange.Style.MONOSPACE -> BodyRangeList.BodyRange.Style.MONOSPACE
BodyRange.Style.SPOILER -> BodyRangeList.BodyRange.Style.SPOILER
BodyRange.Style.STRIKETHROUGH -> BodyRangeList.BodyRange.Style.STRIKETHROUGH
else -> null
}
},
start = bodyRange.start ?: 0,
length = bodyRange.length ?: 0
)
}
)
}
private fun SendStatus.Status.toLocalSendStatus(): Int {
return when (this) {
SendStatus.Status.UNKNOWN -> GroupReceiptTable.STATUS_UNKNOWN
SendStatus.Status.FAILED -> GroupReceiptTable.STATUS_UNKNOWN
SendStatus.Status.PENDING -> GroupReceiptTable.STATUS_UNDELIVERED
SendStatus.Status.SENT -> GroupReceiptTable.STATUS_UNDELIVERED
SendStatus.Status.DELIVERED -> GroupReceiptTable.STATUS_DELIVERED
SendStatus.Status.READ -> GroupReceiptTable.STATUS_READ
SendStatus.Status.VIEWED -> GroupReceiptTable.STATUS_VIEWED
SendStatus.Status.SKIPPED -> GroupReceiptTable.STATUS_SKIPPED
}
}
private class Buffer(
val messages: MutableList<ContentValues> = mutableListOf(),
val reactions: MutableList<ContentValues> = mutableListOf(),
val groupReceipts: MutableList<ContentValues> = mutableListOf()
) {
val size: Int
get() = listOf(messages.size, reactions.size, groupReceipts.size).max()
}
}

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import okio.ByteString.Companion.toByteString
import org.signal.core.util.CursorUtil
import org.signal.core.util.delete
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
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.database.DistributionListTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.DistributionId
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.api.util.toByteArray
import org.thoughtcrime.securesms.backup.v2.proto.DistributionList as BackupDistributionList
private val TAG = Log.tag(DistributionListTables::class.java)
fun DistributionListTables.getAllForBackup(): List<BackupRecipient> {
val records = readableDatabase
.select()
.from(DistributionListTables.ListTable.TABLE_NAME)
.run()
.readToList { cursor ->
val id: DistributionListId = DistributionListId.from(cursor.requireLong(DistributionListTables.ListTable.ID))
val privacyMode: DistributionListPrivacyMode = cursor.requireObject(DistributionListTables.ListTable.PRIVACY_MODE, DistributionListPrivacyMode.Serializer)
DistributionListRecord(
id = id,
name = cursor.requireNonNullString(DistributionListTables.ListTable.NAME),
distributionId = DistributionId.from(cursor.requireNonNullString(DistributionListTables.ListTable.DISTRIBUTION_ID)),
allowsReplies = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.ALLOWS_REPLIES),
rawMembers = getRawMembers(id, privacyMode),
members = getMembers(id),
deletedAtTimestamp = 0L,
isUnknown = CursorUtil.requireBoolean(cursor, DistributionListTables.ListTable.IS_UNKNOWN),
privacyMode = privacyMode
)
}
return records
.map { record ->
BackupRecipient(
distributionList = BackupDistributionList(
name = record.name,
distributionId = record.distributionId.asUuid().toByteArray().toByteString(),
allowReplies = record.allowsReplies,
deletionTimestamp = record.deletedAtTimestamp,
privacyMode = record.privacyMode.toBackupPrivacyMode(),
memberRecipientIds = record.members.map { it.toLong() }
)
)
}
}
fun DistributionListTables.restoreFromBackup(dlist: BackupDistributionList, backupState: BackupState): RecipientId {
val members: List<RecipientId> = dlist.memberRecipientIds
.mapNotNull { backupState.backupToLocalRecipientId[it] }
if (members.size != dlist.memberRecipientIds.size) {
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()
)!!
return SignalDatabase.distributionLists.getRecipientId(dlistId)!!
}
fun DistributionListTables.clearAllDataForBackupRestore() {
writableDatabase
.delete(DistributionListTables.ListTable.TABLE_NAME)
.run()
writableDatabase
.delete(DistributionListTables.MembershipTable.TABLE_NAME)
.run()
}
private fun DistributionListPrivacyMode.toBackupPrivacyMode(): BackupDistributionList.PrivacyMode {
return when (this) {
DistributionListPrivacyMode.ONLY_WITH -> BackupDistributionList.PrivacyMode.ONLY_WITH
DistributionListPrivacyMode.ALL -> BackupDistributionList.PrivacyMode.ALL
DistributionListPrivacyMode.ALL_EXCEPT -> BackupDistributionList.PrivacyMode.ALL_EXCEPT
}
}
private fun BackupDistributionList.PrivacyMode.toLocalPrivacyMode(): DistributionListPrivacyMode {
return when (this) {
BackupDistributionList.PrivacyMode.UNKNOWN -> DistributionListPrivacyMode.ALL
BackupDistributionList.PrivacyMode.ONLY_WITH -> DistributionListPrivacyMode.ONLY_WITH
BackupDistributionList.PrivacyMode.ALL -> DistributionListPrivacyMode.ALL
BackupDistributionList.PrivacyMode.ALL_EXCEPT -> DistributionListPrivacyMode.ALL_EXCEPT
}
}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import org.signal.core.util.SqlUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.select
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageTypes
private val TAG = Log.tag(MessageTable::class.java)
private const val BASE_TYPE = "base_type"
fun MessageTable.getMessagesForBackup(): ChatItemExportIterator {
val cursor = readableDatabase
.select(
MessageTable.ID,
MessageTable.DATE_SENT,
MessageTable.DATE_RECEIVED,
MessageTable.DATE_SERVER,
MessageTable.TYPE,
MessageTable.THREAD_ID,
MessageTable.BODY,
MessageTable.MESSAGE_RANGES,
MessageTable.FROM_RECIPIENT_ID,
MessageTable.TO_RECIPIENT_ID,
MessageTable.EXPIRES_IN,
MessageTable.EXPIRE_STARTED,
MessageTable.REMOTE_DELETED,
MessageTable.UNIDENTIFIED,
MessageTable.QUOTE_ID,
MessageTable.QUOTE_AUTHOR,
MessageTable.QUOTE_BODY,
MessageTable.QUOTE_MISSING,
MessageTable.QUOTE_BODY_RANGES,
MessageTable.QUOTE_TYPE,
MessageTable.ORIGINAL_MESSAGE_ID,
MessageTable.LATEST_REVISION_ID,
MessageTable.HAS_DELIVERY_RECEIPT,
MessageTable.HAS_READ_RECEIPT,
MessageTable.VIEWED_COLUMN,
MessageTable.RECEIPT_TIMESTAMP,
MessageTable.READ,
MessageTable.NETWORK_FAILURES,
MessageTable.MISMATCHED_IDENTITIES,
"${MessageTable.TYPE} & ${MessageTypes.BASE_TYPE_MASK} AS ${ChatItemExportIterator.COLUMN_BASE_TYPE}"
)
.from(MessageTable.TABLE_NAME)
.where(
"""
$BASE_TYPE IN (
${MessageTypes.BASE_INBOX_TYPE},
${MessageTypes.BASE_OUTBOX_TYPE},
${MessageTypes.BASE_SENT_TYPE},
${MessageTypes.BASE_SENDING_TYPE},
${MessageTypes.BASE_SENT_FAILED_TYPE}
)
"""
)
.orderBy("${MessageTable.DATE_RECEIVED} ASC")
.run()
return ChatItemExportIterator(cursor, 100)
}
fun MessageTable.createChatItemInserter(backupState: BackupState): ChatItemImportInserter {
return ChatItemImportInserter(writableDatabase, backupState, 100)
}
fun MessageTable.clearAllDataForBackupRestore() {
writableDatabase.delete(MessageTable.TABLE_NAME, null, null)
SqlUtil.resetAutoIncrementValue(writableDatabase, MessageTable.TABLE_NAME)
}

View File

@@ -0,0 +1,332 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.content.ContentValues
import android.database.Cursor
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.logging.Log
import org.signal.core.util.nullIfBlank
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullBlob
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.signal.core.util.update
import org.signal.libsignal.zkgroup.InvalidInputException
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.Contact
import org.thoughtcrime.securesms.backup.v2.proto.Group
import org.thoughtcrime.securesms.backup.v2.proto.Self
import org.thoughtcrime.securesms.database.GroupTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.RecipientTableCursorUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.RecipientExtras
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.io.Closeable
typealias BackupRecipient = org.thoughtcrime.securesms.backup.v2.proto.Recipient
typealias BackupGroup = Group
/**
* Fetches all individual contacts for backups and returns the result as an iterator.
* It's important to note that the iterator still needs to be closed after it's used.
* It's recommended to use `.use` or a try-with-resources pattern.
*/
fun RecipientTable.getContactsForBackup(selfId: Long): BackupContactIterator {
val cursor = readableDatabase
.select(
RecipientTable.ID,
RecipientTable.ACI_COLUMN,
RecipientTable.PNI_COLUMN,
RecipientTable.USERNAME,
RecipientTable.E164,
RecipientTable.BLOCKED,
RecipientTable.HIDDEN,
RecipientTable.REGISTERED,
RecipientTable.UNREGISTERED_TIMESTAMP,
RecipientTable.PROFILE_KEY,
RecipientTable.PROFILE_SHARING,
RecipientTable.PROFILE_GIVEN_NAME,
RecipientTable.PROFILE_FAMILY_NAME,
RecipientTable.PROFILE_JOINED_NAME,
RecipientTable.MUTE_UNTIL,
RecipientTable.EXTRAS
)
.from(RecipientTable.TABLE_NAME)
.where(
"""
${RecipientTable.TYPE} = ? AND (
${RecipientTable.ACI_COLUMN} NOT NULL OR
${RecipientTable.PNI_COLUMN} NOT NULL OR
${RecipientTable.E164} NOT NULL
)
""",
RecipientTable.RecipientType.INDIVIDUAL.id
)
.run()
return BackupContactIterator(cursor, selfId)
}
fun RecipientTable.getGroupsForBackup(): BackupGroupIterator {
val cursor = readableDatabase
.select(
"${RecipientTable.TABLE_NAME}.${RecipientTable.ID}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_SHARING}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.MUTE_UNTIL}",
"${RecipientTable.TABLE_NAME}.${RecipientTable.EXTRAS}",
"${GroupTable.TABLE_NAME}.${GroupTable.V2_MASTER_KEY}",
"${GroupTable.TABLE_NAME}.${GroupTable.SHOW_AS_STORY_STATE}"
)
.from(
"""
${RecipientTable.TABLE_NAME}
INNER JOIN ${GroupTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = ${GroupTable.TABLE_NAME}.${GroupTable.RECIPIENT_ID}
"""
)
.run()
return BackupGroupIterator(cursor)
}
/**
* Takes a [BackupRecipient] and writes it into the database.
*/
fun RecipientTable.restoreRecipientFromBackup(recipient: BackupRecipient, backupState: BackupState): RecipientId? {
// TODO Need to handle groups
// TODO Also, should we move this when statement up to mimic the export? Kinda weird that this calls distributionListTable functions
return when {
recipient.contact != null -> restoreContactFromBackup(recipient.contact)
recipient.distributionList != null -> SignalDatabase.distributionLists.restoreFromBackup(recipient.distributionList, backupState)
recipient.self != null -> Recipient.self().id
else -> {
Log.w(TAG, "Unrecognized recipient type!")
null
}
}
}
/**
* Given [AccountData], this will insert the necessary data for the local user into the [RecipientTable].
*/
fun RecipientTable.restoreSelfFromBackup(accountData: AccountData, selfId: RecipientId) {
val values = ContentValues().apply {
put(RecipientTable.PROFILE_GIVEN_NAME, accountData.givenName.nullIfBlank())
put(RecipientTable.PROFILE_FAMILY_NAME, accountData.familyName.nullIfBlank())
put(RecipientTable.PROFILE_JOINED_NAME, ProfileName.fromParts(accountData.givenName, accountData.familyName).toString().nullIfBlank())
put(RecipientTable.PROFILE_AVATAR, accountData.avatarUrlPath.nullIfBlank())
put(RecipientTable.REGISTERED, RecipientTable.RegisteredState.REGISTERED.id)
put(RecipientTable.PROFILE_SHARING, true)
put(RecipientTable.UNREGISTERED_TIMESTAMP, 0)
put(RecipientTable.EXTRAS, RecipientExtras().encode())
try {
put(RecipientTable.PROFILE_KEY, Base64.encodeWithPadding(accountData.profileKey.toByteArray()).nullIfBlank())
} catch (e: InvalidInputException) {
Log.w(TAG, "Missing profile key during restore")
}
put(RecipientTable.USERNAME, accountData.username)
}
writableDatabase
.update(RecipientTable.TABLE_NAME)
.values(values)
.where("${RecipientTable.ID} = ?", selfId)
.run()
}
fun RecipientTable.clearAllDataForBackupRestore() {
writableDatabase.delete(RecipientTable.TABLE_NAME).run()
SqlUtil.resetAutoIncrementValue(writableDatabase, RecipientTable.TABLE_NAME)
RecipientId.clearCache()
ApplicationDependencies.getRecipientCache().clear()
ApplicationDependencies.getRecipientCache().clearSelf()
}
private fun RecipientTable.restoreContactFromBackup(contact: Contact): RecipientId {
val id = getAndPossiblyMergePnpVerified(
aci = ACI.parseOrNull(contact.aci?.toByteArray()),
pni = PNI.parseOrNull(contact.pni?.toByteArray()),
e164 = contact.formattedE164
)
val profileKey = contact.profileKey?.toByteArray()
writableDatabase
.update(RecipientTable.TABLE_NAME)
.values(
RecipientTable.BLOCKED to contact.blocked,
RecipientTable.HIDDEN to contact.hidden,
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()
)
.where("${RecipientTable.ID} = ?", id)
.run()
return id
}
private fun Contact.toLocalExtras(): RecipientExtras {
return RecipientExtras(
hideStory = this.hideStory
)
}
/**
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
*/
class BackupContactIterator(private val cursor: Cursor, private val selfId: Long) : Iterator<BackupRecipient?>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): BackupRecipient? {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
val id = cursor.requireLong(RecipientTable.ID)
if (id == selfId) {
return BackupRecipient(
id = id,
self = Self()
)
}
val aci = ACI.parseOrNull(cursor.requireString(RecipientTable.ACI_COLUMN))
val pni = PNI.parseOrNull(cursor.requireString(RecipientTable.PNI_COLUMN))
val e164 = cursor.requireString(RecipientTable.E164)?.e164ToLong()
val registeredState = RecipientTable.RegisteredState.fromId(cursor.requireInt(RecipientTable.REGISTERED))
val profileKey = cursor.requireString(RecipientTable.PROFILE_KEY)
val extras = RecipientTableCursorUtil.getExtras(cursor)
if (aci == null && pni == null && e164 == null) {
return null
}
return BackupRecipient(
id = id,
contact = Contact(
aci = aci?.toByteArray()?.toByteString(),
pni = pni?.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
)
)
}
override fun close() {
cursor.close()
}
}
/**
* Provides a nice iterable interface over a [RecipientTable] cursor, converting rows to [BackupRecipient]s.
* Important: Because this is backed by a cursor, you must close it. It's recommended to use `.use()` or try-with-resources.
*/
class BackupGroupIterator(private val cursor: Cursor) : Iterator<BackupRecipient>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): BackupRecipient {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
val extras = RecipientTableCursorUtil.getExtras(cursor)
val showAsStoryState: GroupTable.ShowAsStoryState = GroupTable.ShowAsStoryState.deserialize(cursor.requireInt(GroupTable.SHOW_AS_STORY_STATE))
return BackupRecipient(
id = cursor.requireLong(RecipientTable.ID),
group = BackupGroup(
masterKey = cursor.requireNonNullBlob(GroupTable.V2_MASTER_KEY).toByteString(),
whitelisted = cursor.requireBoolean(RecipientTable.PROFILE_SHARING),
hideStory = extras?.hideStory() ?: false,
storySendMode = showAsStoryState.toGroupStorySendMode()
)
)
}
override fun close() {
cursor.close()
}
}
private fun String.e164ToLong(): Long? {
val fixed = if (this.startsWith("+")) {
this.substring(1)
} else {
this
}
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
GroupTable.ShowAsStoryState.NEVER -> Group.StorySendMode.DISABLED
GroupTable.ShowAsStoryState.IF_ACTIVE -> Group.StorySendMode.DEFAULT
}
}
private val Contact.formattedE164: String?
get() {
return e164?.let {
PhoneNumberFormatter.get(ApplicationDependencies.getApplication()).format(e164.toString())
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.database
import android.database.Cursor
import org.signal.core.util.SqlUtil
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.select
import org.signal.core.util.toInt
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.recipients.RecipientId
import java.io.Closeable
private val TAG = Log.tag(ThreadTable::class.java)
fun ThreadTable.getThreadsForBackup(): ChatIterator {
val cursor = readableDatabase
.select(
ThreadTable.ID,
ThreadTable.RECIPIENT_ID,
ThreadTable.ARCHIVED,
ThreadTable.PINNED,
ThreadTable.EXPIRES_IN
)
.from(ThreadTable.TABLE_NAME)
.run()
return ChatIterator(cursor)
}
fun ThreadTable.clearAllDataForBackupRestore() {
writableDatabase.delete(ThreadTable.TABLE_NAME, null, null)
SqlUtil.resetAutoIncrementValue(writableDatabase, ThreadTable.TABLE_NAME)
clearCache()
}
fun ThreadTable.restoreFromBackup(chat: Chat, recipientId: RecipientId): Long? {
return writableDatabase
.insertInto(ThreadTable.TABLE_NAME)
.values(
ThreadTable.RECIPIENT_ID to recipientId.serialize(),
ThreadTable.PINNED to chat.pinnedOrder,
ThreadTable.ARCHIVED to chat.archived.toInt()
)
.run()
}
class ChatIterator(private val cursor: Cursor) : Iterator<Chat>, Closeable {
override fun hasNext(): Boolean {
return cursor.count > 0 && !cursor.isLast
}
override fun next(): Chat {
if (!cursor.moveToNext()) {
throw NoSuchElementException()
}
return Chat(
id = cursor.requireLong(ThreadTable.ID),
recipientId = cursor.requireLong(ThreadTable.RECIPIENT_ID),
archived = cursor.requireBoolean(ThreadTable.ARCHIVED),
pinnedOrder = cursor.requireInt(ThreadTable.PINNED),
expirationTimerMs = cursor.requireLong(ThreadTable.EXPIRES_IN)
)
}
override fun close() {
cursor.close()
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.processor
import okio.ByteString.Companion.EMPTY
import okio.ByteString.Companion.toByteString
import org.thoughtcrime.securesms.backup.v2.database.restoreSelfFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.AccountData
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
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.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.subscription.Subscriber
import org.thoughtcrime.securesms.util.ProfileUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
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
object AccountDataProcessor {
fun export(emitter: BackupFrameEmitter) {
val context = ApplicationDependencies.getApplication()
val self = Recipient.self().fresh()
val record = recipients.getRecordForSync(self.id)
val subscriber: Subscriber? = SignalStore.donationsValues().getSubscriber()
emitter.emit(
Frame(
account = AccountData(
profileKey = self.profileKey?.toByteString() ?: EMPTY,
givenName = self.profileName.givenName,
familyName = self.profileName.familyName,
avatarUrlPath = self.profileAvatar ?: "",
subscriptionManuallyCancelled = SignalStore.donationsValues().isUserManuallyCancelled(),
username = SignalStore.account().username,
subscriberId = subscriber?.subscriberId?.bytes?.toByteString() ?: defaultAccountRecord.subscriberId,
subscriberCurrencyCode = subscriber?.currencyCode ?: defaultAccountRecord.subscriberCurrencyCode,
accountSettings = AccountData.AccountSettings(
storyViewReceiptsEnabled = SignalStore.storyValues().viewedReceiptsEnabled,
noteToSelfMarkedUnread = record != null && record.syncExtras.isForcedUnread,
typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context),
readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context),
sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context),
linkPreviews = SignalStore.settings().isLinkPreviewsEnabled,
notDiscoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode.isUnlisted,
phoneNumberSharingMode = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode.toBackupPhoneNumberSharingMode(),
preferContactAvatars = SignalStore.settings().isPreferSystemContactPhotos,
universalExpireTimer = SignalStore.settings().universalExpireTimer,
preferredReactionEmoji = SignalStore.emojiValues().reactions,
storiesDisabled = SignalStore.storyValues().isFeatureDisabled,
hasViewedOnboardingStory = SignalStore.storyValues().userHasViewedOnboardingStory,
hasSetMyStoriesPrivacy = SignalStore.storyValues().userHasBeenNotifiedAboutStories,
keepMutedChatsArchived = SignalStore.settings().shouldKeepMutedChatsArchived(),
displayBadgesOnProfile = SignalStore.donationsValues().getDisplayBadgesOnProfile(),
hasSeenGroupStoryEducationSheet = SignalStore.storyValues().userHasSeenGroupStoryEducationSheet
)
)
)
)
}
fun import(accountData: AccountData, selfId: RecipientId) {
recipients.restoreSelfFromBackup(accountData, selfId)
SignalStore.account().setRegistered(true)
val context = ApplicationDependencies.getApplication()
val settings = accountData.accountSettings
if (settings != null) {
TextSecurePreferences.setReadReceiptsEnabled(context, settings.readReceipts)
TextSecurePreferences.setTypingIndicatorsEnabled(context, settings.typingIndicators)
TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, settings.sealedSenderIndicators)
SignalStore.settings().isLinkPreviewsEnabled = settings.linkPreviews
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (settings.notDiscoverableByPhoneNumber) PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED else PhoneNumberPrivacyValues.PhoneNumberListingMode.LISTED
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
if (accountData.subscriptionManuallyCancelled) {
SignalStore.donationsValues().updateLocalStateForManualCancellation()
} else {
SignalStore.donationsValues().clearUserManuallyCancelled()
}
if (accountData.subscriberId.size > 0) {
val subscriber = Subscriber(SubscriberId.fromBytes(accountData.subscriberId.toByteArray()), accountData.subscriberCurrencyCode)
SignalStore.donationsValues().setSubscriber(subscriber)
}
if (accountData.avatarUrlPath.isNotEmpty()) {
ApplicationDependencies.getJobManager().add(RetrieveProfileAvatarJob(Recipient.self().fresh(), accountData.avatarUrlPath))
}
if (accountData.usernameLink != null) {
SignalStore.account().usernameLink = UsernameLinkComponents(
accountData.usernameLink.entropy.toByteArray(),
UuidUtil.parseOrThrow(accountData.usernameLink.serverId.toByteArray())
)
SignalStore.misc().usernameQrCodeColorScheme = accountData.usernameLink.color.toLocalUsernameColor()
}
}
SignalDatabase.runPostSuccessfulTransaction { ProfileUtil.handleSelfProfileKeyChange() }
Recipient.self().live().refresh()
}
private fun PhoneNumberPrivacyValues.PhoneNumberSharingMode.toBackupPhoneNumberSharingMode(): AccountData.PhoneNumberSharingMode {
return when (this) {
PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountData.PhoneNumberSharingMode.EVERYBODY
PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountData.PhoneNumberSharingMode.EVERYBODY
PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY -> AccountData.PhoneNumberSharingMode.NOBODY
}
}
private fun AccountData.PhoneNumberSharingMode.toLocalPhoneNumberMode(): PhoneNumberPrivacyValues.PhoneNumberSharingMode {
return when (this) {
AccountData.PhoneNumberSharingMode.UNKNOWN -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY
AccountData.PhoneNumberSharingMode.EVERYBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY
AccountData.PhoneNumberSharingMode.NOBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY
}
}
private fun AccountData.UsernameLink.Color?.toLocalUsernameColor(): UsernameQrCodeColorScheme {
return when (this) {
AccountData.UsernameLink.Color.BLUE -> UsernameQrCodeColorScheme.Blue
AccountData.UsernameLink.Color.WHITE -> UsernameQrCodeColorScheme.White
AccountData.UsernameLink.Color.GREY -> UsernameQrCodeColorScheme.Grey
AccountData.UsernameLink.Color.OLIVE -> UsernameQrCodeColorScheme.Tan
AccountData.UsernameLink.Color.GREEN -> UsernameQrCodeColorScheme.Green
AccountData.UsernameLink.Color.ORANGE -> UsernameQrCodeColorScheme.Orange
AccountData.UsernameLink.Color.PINK -> UsernameQrCodeColorScheme.Pink
AccountData.UsernameLink.Color.PURPLE -> UsernameQrCodeColorScheme.Purple
else -> UsernameQrCodeColorScheme.Blue
}
}
}

View File

@@ -0,0 +1,44 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.database.getThreadsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.Chat
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.recipients.RecipientId
object ChatBackupProcessor {
val TAG = Log.tag(ChatBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
SignalDatabase.threads.getThreadsForBackup().use { reader ->
for (chat in reader) {
emitter.emit(Frame(chat = chat))
}
}
}
fun import(chat: Chat, backupState: BackupState) {
val recipientId: RecipientId? = backupState.backupToLocalRecipientId[chat.recipientId]
if (recipientId == null) {
Log.w(TAG, "Missing recipient for chat ${chat.id}")
return
}
SignalDatabase.threads.restoreFromBackup(chat, recipientId)?.let { threadId ->
backupState.chatIdToLocalRecipientId[chat.id] = recipientId
backupState.chatIdToLocalThreadId[chat.id] = threadId
backupState.chatIdToBackupRecipientId[chat.id] = chat.recipientId
}
// TODO there's several fields in the chat that actually need to be restored on the recipient table
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.createChatItemInserter
import org.thoughtcrime.securesms.backup.v2.database.getMessagesForBackup
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
object ChatItemBackupProcessor {
val TAG = Log.tag(ChatItemBackupProcessor::class.java)
fun export(emitter: BackupFrameEmitter) {
SignalDatabase.messages.getMessagesForBackup().use { chatItems ->
for (chatItem in chatItems) {
emitter.emit(Frame(chatItem = chatItem))
}
}
}
fun beginImport(backupState: BackupState): ChatItemImportInserter {
return SignalDatabase.messages.createChatItemInserter(backupState)
}
}

View File

@@ -0,0 +1,53 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.processor
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupState
import org.thoughtcrime.securesms.backup.v2.database.getAllForBackup
import org.thoughtcrime.securesms.backup.v2.database.getContactsForBackup
import org.thoughtcrime.securesms.backup.v2.database.getGroupsForBackup
import org.thoughtcrime.securesms.backup.v2.database.restoreRecipientFromBackup
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.thoughtcrime.securesms.backup.v2.stream.BackupFrameEmitter
import org.thoughtcrime.securesms.database.SignalDatabase
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(emitter: BackupFrameEmitter) {
val selfId = Recipient.self().id.toLong()
SignalDatabase.recipients.getContactsForBackup(selfId).use { reader ->
for (backupRecipient in reader) {
if (backupRecipient != null) {
emitter.emit(Frame(recipient = backupRecipient))
}
}
}
SignalDatabase.recipients.getGroupsForBackup().use { reader ->
for (backupRecipient in reader) {
emitter.emit(Frame(recipient = backupRecipient))
}
}
SignalDatabase.distributionLists.getAllForBackup().forEach {
emitter.emit(Frame(recipient = it))
}
}
fun import(recipient: BackupRecipient, backupState: BackupState) {
val newId = SignalDatabase.recipients.restoreRecipientFromBackup(recipient, backupState)
if (newId != null) {
backupState.backupToLocalRecipientId[recipient.id] = newId
}
}
}

View File

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

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupExportWriter : AutoCloseable {
fun write(frame: Frame)
}

View File

@@ -0,0 +1,15 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
/**
* An interface that lets sub-processors emit [Frame]s as they export data.
*/
fun interface BackupFrameEmitter {
fun emit(frame: Frame)
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
interface BackupImportStream {
fun read(): Frame?
}

View File

@@ -0,0 +1,112 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.readFully
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.signal.core.util.stream.MacInputStream
import org.signal.core.util.stream.TruncatingInputStream
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.EOFException
import java.io.IOException
import java.io.InputStream
import java.util.zip.GZIPInputStream
import javax.crypto.Cipher
import javax.crypto.CipherInputStream
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Provides the ability to read backup frames in a streaming fashion from a target [InputStream].
* As it's being read, it will be both decrypted and uncompressed. Specifically, the data is decrypted,
* that decrypted data is gunzipped, then that data is read as frames.
*/
class EncryptedBackupReader(
key: BackupKey,
aci: ACI,
streamLength: Long,
dataStream: () -> InputStream
) : Iterator<Frame>, AutoCloseable {
var next: Frame? = null
val stream: InputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.DECRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
}
validateMac(keyMaterial.macKey, streamLength, dataStream())
stream = GZIPInputStream(
CipherInputStream(
TruncatingInputStream(
wrapped = dataStream(),
maxBytes = streamLength - MAC_SIZE
),
cipher
)
)
next = read()
}
override fun hasNext(): Boolean {
return next != null
}
override fun next(): Frame {
next?.let { out ->
next = read()
return out
} ?: throw NoSuchElementException()
}
private fun read(): Frame? {
try {
val length = stream.readVarInt32().also { if (it < 0) return null }
val frameBytes: ByteArray = stream.readNBytesOrThrow(length)
return Frame.ADAPTER.decode(frameBytes)
} catch (e: EOFException) {
return null
}
}
override fun close() {
stream.close()
}
companion object {
const val MAC_SIZE = 32
fun validateMac(macKey: ByteArray, streamLength: Long, dataStream: InputStream) {
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(macKey, "HmacSHA256"))
}
val macStream = MacInputStream(
wrapped = TruncatingInputStream(dataStream, maxBytes = streamLength - MAC_SIZE),
mac = mac
)
macStream.readFully(false)
val calculatedMac = macStream.mac.doFinal()
val expectedMac = dataStream.readNBytesOrThrow(MAC_SIZE)
if (!calculatedMac.contentEquals(expectedMac)) {
throw IOException("Invalid MAC!")
}
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.stream.MacOutputStream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import org.whispersystems.signalservice.api.backup.BackupKey
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import java.io.IOException
import java.io.OutputStream
import java.util.zip.GZIPOutputStream
import javax.crypto.Cipher
import javax.crypto.CipherOutputStream
import javax.crypto.Mac
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* Provides the ability to write backup frames in a streaming fashion to a target [OutputStream].
* As it's being written, it will be both encrypted and compressed. Specifically, the backup frames
* are gzipped, that gzipped data is encrypted, and then an HMAC of the encrypted data is appended
* to the end of the [outputStream].
*/
class EncryptedBackupWriter(
key: BackupKey,
aci: ACI,
private val outputStream: OutputStream,
private val append: (ByteArray) -> Unit
) : BackupExportWriter {
private val mainStream: GZIPOutputStream
private val macStream: MacOutputStream
init {
val keyMaterial = key.deriveSecrets(aci)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply {
init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyMaterial.cipherKey, "AES"), IvParameterSpec(keyMaterial.iv))
}
val mac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(keyMaterial.macKey, "HmacSHA256"))
}
macStream = MacOutputStream(outputStream, mac)
mainStream = GZIPOutputStream(
CipherOutputStream(
macStream,
cipher
)
)
}
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()
mainStream.writeVarInt32(frameBytes.size)
mainStream.write(frameBytes)
}
@Throws(IOException::class)
override fun close() {
// We need to close the main stream in order for the gzip and all the cipher operations to fully finish before
// we can calculate the MAC. Unfortunately flush()/finish() is not sufficient. So we have to defer to the
// caller to append the bytes to the end of the data however they see fit (like appending to a file).
mainStream.close()
val mac = macStream.mac.doFinal()
append(mac)
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.readNBytesOrThrow
import org.signal.core.util.readVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.EOFException
import java.io.InputStream
/**
* Reads a plaintext backup import stream one frame at a time.
*/
class PlainTextBackupReader(val inputStream: InputStream) : Iterator<Frame> {
var next: Frame? = null
init {
next = read()
}
override fun hasNext(): Boolean {
return next != null
}
override fun next(): Frame {
next?.let { out ->
next = read()
return out
} ?: throw NoSuchElementException()
}
private fun read(): Frame? {
try {
val length = inputStream.readVarInt32().also { if (it < 0) return null }
val frameBytes: ByteArray = inputStream.readNBytesOrThrow(length)
return Frame.ADAPTER.decode(frameBytes)
} catch (e: EOFException) {
return null
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.backup.v2.stream
import org.signal.core.util.writeVarInt32
import org.thoughtcrime.securesms.backup.v2.proto.Frame
import java.io.IOException
import java.io.OutputStream
/**
* Writes backup frames to the wrapped stream in plain text. Only for testing!
*/
class PlainTextBackupWriter(private val outputStream: OutputStream) : BackupExportWriter {
@Throws(IOException::class)
override fun write(frame: Frame) {
val frameBytes: ByteArray = frame.encode()
outputStream.writeVarInt32(frameBytes.size)
outputStream.write(frameBytes)
}
override fun close() {
outputStream.close()
}
}

View File

@@ -14,6 +14,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.InputAwareLayout
@@ -286,9 +287,9 @@ class GiftFlowConfirmationFragment :
override fun onProcessorActionProcessed() = Unit
override fun onUserCancelledPaymentFlow() {
findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false)
}
override fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney) = error("Unsupported operation")
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
}

View File

@@ -1,52 +0,0 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.CommunicationActions
class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
SplashImage.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
customPref(SplashImage.Model(R.drawable.ic_card_process))
sectionHeaderPref(
title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
)
textPref(
summary = DSLSettingsText.from(
requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
},
DSLSettingsText.CenterModifier
)
)
primaryButton(
text = DSLSettingsText.from(android.R.string.ok)
) {
dismissAllowingStateLoss()
}
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
) {
SignalStore.donationsValues().showCantProcessDialog = false
dismissAllowingStateLoss()
}
}
}
}

View File

@@ -1,167 +0,0 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(
DSLSettingsText.from(
if (badge.isBoost()) {
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
} else if (declineCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(declineCode.mapToErrorStringResource()),
badge.name
)
} else if (failureCode != null) {
getString(
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
getString(failureCode.mapToErrorStringResource()),
badge.name
)
} else if (inactive) {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
} else {
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
space(DimensionUnit.DP.toPixels(68f).toInt())
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
onClick = {
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
}
)
} else {
noPadTextPref(
DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
}
primaryButton(
text = DSLSettingsText.from(
if (badge.isBoost()) {
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
}
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
}
),
onClick = {
dismiss()
if (isLikelyASustainer) {
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
} else {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
companion object {
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,121 @@
package org.thoughtcrime.securesms.badges.self.expired
import androidx.fragment.app.FragmentManager
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
/**
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
*/
class ExpiredOneTimeBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
peekHeightPercentage = 1f
) {
override fun bindAdapter(adapter: DSLSettingsAdapter) {
ExpiredBadge.register(adapter)
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
val badge: Badge = args.badge
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
return configure {
customPref(ExpiredBadge.Model(badge))
sectionHeaderPref(
DSLSettingsText.from(
if (badge.isBoost()) {
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(4f).toInt())
noPadTextPref(
DSLSettingsText.from(
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and),
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(16f).toInt())
noPadTextPref(
DSLSettingsText.from(
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
},
DSLSettingsText.CenterModifier
)
)
space(DimensionUnit.DP.toPixels(92f).toInt())
primaryButton(
text = DSLSettingsText.from(
if (isLikelyASustainer) {
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
} else {
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
}
),
onClick = {
dismiss()
if (isLikelyASustainer) {
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
} else {
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
}
}
)
secondaryButtonNoOutline(
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
onClick = {
dismiss()
}
)
}
}
companion object {
private val TAG = Log.tag(ExpiredOneTimeBadgeBottomSheetDialogFragment::class.java)
@JvmStatic
fun show(
badge: Badge,
cancellationReason: UnexpectedSubscriptionCancellation?,
chargeFailure: ActiveSubscription.ChargeFailure?,
fragmentManager: FragmentManager
) {
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
val fragment = ExpiredOneTimeBadgeBottomSheetDialogFragment()
fragment.arguments = args.toBundle()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.badges.self.expired
import android.content.res.Configuration
import android.net.Uri
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.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.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
val errorMessage = if (declineCode.isKnown()) {
declineCode.mapToErrorStringResource()
} else if (failureCode.isKnown) {
failureCode.mapToErrorStringResource()
} else {
declineCode.mapToErrorStringResource()
}
MonthlyDonationCanceled(
badge = SignalStore.donationsValues().getExpiredBadge(),
errorMessageRes = errorMessage,
onRenewClicked = {
startActivity(AppSettingsActivity.subscriptions(requireContext()))
dismissAllowingStateLoss()
},
onNotNowClicked = {
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = false
dismissAllowingStateLoss()
}
)
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun MonthlyDonationCanceledPreview() {
SignalTheme {
Surface {
MonthlyDonationCanceled(
badge = Badge(
id = "",
category = Badge.Category.Donor,
name = "Signal Star",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "",
expirationTimestamp = 0L,
visible = true,
duration = 0L
),
errorMessageRes = R.string.StripeFailureCode__verify_your_bank_details_are_correct,
onRenewClicked = {},
onNotNowClicked = {}
)
}
}
}
@Composable
private fun MonthlyDonationCanceled(
badge: Badge?,
@StringRes errorMessageRes: Int,
onRenewClicked: () -> Unit,
onNotNowClicked: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 34.dp)
) {
BottomSheets.Handle()
if (badge != null) {
Box(modifier = Modifier.padding(top = 21.dp, bottom = 16.dp)) {
BadgeImage112(
badge = badge,
modifier = Modifier
.size(80.dp)
)
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_error_circle_fill_24),
contentScale = ContentScale.Inside,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
modifier = Modifier
.size(24.dp)
.align(Alignment.TopEnd)
.background(
color = SignalTheme.colors.colorSurface1,
shape = CircleShape
)
)
}
}
Text(
text = stringResource(id = R.string.MonthlyDonationCanceled__title),
style = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface),
modifier = Modifier.padding(bottom = 20.dp)
)
val context = LocalContext.current
val learnMore = stringResource(id = R.string.MonthlyDonationCanceled__learn_more)
val errorMessage = stringResource(id = errorMessageRes)
val fullString = stringResource(id = R.string.MonthlyDonationCanceled__message, errorMessage, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, ManageDonationsFragment.DONATE_TROUBLESHOOTING_URL)
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier.padding(bottom = 36.dp)
)
Buttons.LargeTonal(
onClick = onRenewClicked,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__renew_button))
}
TextButton(
onClick = onNotNowClicked,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__not_now_button))
}
}
}

View File

@@ -58,7 +58,7 @@ object CallLinks {
return false
}
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
if (!url.startsWith(HTTPS_LINK_PREFIX) || !url.startsWith(SNGL_LINK_PREFIX)) {
return false
}

View File

@@ -30,9 +30,8 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -318,7 +317,7 @@ public class ConversationItemFooter extends ConstraintLayout {
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
dateView.setText(DateUtils.getOnlyTimeString(getContext(), ((MmsMessageRecord) messageRecord).getScheduledDate()));
} else {
long timestamp = messageRecord.getTimestamp();
if (messageRecord.isEditMessage()) {
@@ -328,7 +327,11 @@ public class ConversationItemFooter extends ConstraintLayout {
}
String date = DateUtils.getDatelessRelativeTimeSpanString(getContext(), locale, timestamp);
if (displayMode != ConversationItemDisplayMode.Detailed.INSTANCE && messageRecord.isEditMessage() && messageRecord.isLatestRevision()) {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
if (DateUtils.isNow(timestamp)) {
date = getContext().getString(R.string.ConversationItem_edited_now_timestamp_footer);
} else {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
}
}
dateView.setText(date);
}
@@ -417,7 +420,7 @@ public class ConversationItemFooter extends ConstraintLayout {
deliveryStatusView.setNone();
} else if (messageRecord.isPending()) {
deliveryStatusView.setPending();
} else if (messageRecord.isRemoteRead()) {
} else if (messageRecord.hasReadReceipt()) {
deliveryStatusView.setRead();
} else if (messageRecord.isDelivered()) {
deliveryStatusView.setDelivered();
@@ -434,7 +437,7 @@ public class ConversationItemFooter extends ConstraintLayout {
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
showAudioDurationViews();
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
if (messageRecord.isViewed() || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
revealDot.setProgress(1f);
} else {
revealDot.setProgress(0f);

View File

@@ -105,6 +105,11 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
}
}
override fun onStart() {
super.onStart()
viewModel.onVisible()
}
private fun submitLogs(debugLog: String, purpose: Purpose) {
CommunicationActions.openEmail(
requireContext(),

View File

@@ -42,7 +42,9 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
override fun onResume() {
super.onResume()
WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!)
dialog?.window?.let { window ->
WindowUtil.initializeScreenshotSecurity(requireContext(), window)
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {

View File

@@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdap
import org.thoughtcrime.securesms.conversation.MessageStyler;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftTable;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
@@ -423,10 +423,10 @@ public class InputPanel extends ConstraintLayout
}
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
if (messageToEdit instanceof MediaMmsMessageRecord) {
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
if (messageToEdit instanceof MmsMessageRecord) {
MmsMessageRecord mediaEditMessage = (MmsMessageRecord) messageToEdit;
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
editMessageThumbnail.setVisibility(VISIBLE);

View File

@@ -51,13 +51,14 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
private val windowTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
}
private val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
protected val statusBarGuideline: Guideline? by lazy { findViewById(R.id.status_bar_guideline) }
private val navigationBarGuideline: Guideline? by lazy { findViewById(R.id.navigation_bar_guideline) }
private val parentStartGuideline: Guideline? by lazy { findViewById(R.id.parent_start_guideline) }
private val parentEndGuideline: Guideline? by lazy { findViewById(R.id.parent_end_guideline) }
private val keyboardGuideline: Guideline? by lazy { findViewById(R.id.keyboard_guideline) }
private val listeners: MutableList<KeyboardStateListener> = mutableListOf()
private val windowInsetsListeners: MutableSet<WindowInsetsListener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = mutableSetOf()
private val keyboardAnimator = KeyboardInsetAnimator()
private val displayMetrics = DisplayMetrics()
private var overridingKeyboard: Boolean = false
@@ -82,20 +83,35 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
fun addKeyboardStateListener(listener: KeyboardStateListener) {
listeners += listener
keyboardStateListeners += listener
}
fun removeKeyboardStateListener(listener: KeyboardStateListener) {
listeners.remove(listener)
keyboardStateListeners.remove(listener)
}
fun addWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners += listener
}
fun removeWindowInsetsListener(listener: WindowInsetsListener) {
windowInsetsListeners.remove(listener)
}
private fun applyInsets(windowInsets: Insets, keyboardInsets: Insets) {
val isLtr = ViewUtil.isLtr(this)
statusBarGuideline?.setGuidelineBegin(windowInsets.top)
navigationBarGuideline?.setGuidelineEnd(windowInsets.bottom)
parentStartGuideline?.setGuidelineBegin(if (isLtr) windowInsets.left else windowInsets.right)
parentEndGuideline?.setGuidelineEnd(if (isLtr) windowInsets.right else windowInsets.left)
val statusBar = windowInsets.top
val navigationBar = windowInsets.bottom
val parentStart = if (isLtr) windowInsets.left else windowInsets.right
val parentEnd = if (isLtr) windowInsets.right else windowInsets.left
statusBarGuideline?.setGuidelineBegin(statusBar)
navigationBarGuideline?.setGuidelineEnd(navigationBar)
parentStartGuideline?.setGuidelineBegin(parentStart)
parentEndGuideline?.setGuidelineEnd(parentEnd)
windowInsetsListeners.forEach { it.onApplyWindowInsets(statusBar, navigationBar, parentStart, parentEnd) }
if (keyboardInsets.bottom > 0) {
setKeyboardHeight(keyboardInsets.bottom)
@@ -113,7 +129,7 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
}
if (previousKeyboardHeight != keyboardInsets.bottom) {
listeners.forEach {
keyboardStateListeners.forEach {
if (previousKeyboardHeight <= 0) {
it.onKeyboardShown()
} else {
@@ -191,6 +207,10 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor(
fun onKeyboardHidden()
}
interface WindowInsetsListener {
fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int)
}
/**
* Adjusts the [keyboardGuideline] to move with the IME keyboard opening or closing.
*/

View File

@@ -19,13 +19,13 @@ import org.thoughtcrime.securesms.crash.CrashConfig
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogRepository
class PromptLogsViewModel(private val context: Application, purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) {
class PromptLogsViewModel(private val context: Application, private val purpose: DebugLogsPromptDialogFragment.Purpose) : AndroidViewModel(context) {
private val submitDebugLogRepository = SubmitDebugLogRepository()
private val disposables = CompositeDisposable()
init {
fun onVisible() {
if (purpose == DebugLogsPromptDialogFragment.Purpose.CRASH) {
disposables += Single
.fromCallable {

View File

@@ -17,13 +17,15 @@ import androidx.recyclerview.widget.RecyclerView;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.CursorRecyclerViewAdapter;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.mediapreview.MediaPreviewCache;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.util.MediaUtil;
import java.util.ArrayList;
import java.util.List;
public class ThreadPhotoRailView extends FrameLayout {
@NonNull private final RecyclerView recyclerView;
@@ -56,11 +58,11 @@ public class ThreadPhotoRailView extends FrameLayout {
}
}
public void setCursor(@NonNull GlideRequests glideRequests, @Nullable Cursor cursor) {
this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), glideRequests, cursor, this.listener));
public void setMediaRecords(@NonNull GlideRequests glideRequests, @NonNull List<MediaTable.MediaRecord> mediaRecords) {
this.recyclerView.setAdapter(new ThreadPhotoRailAdapter(getContext(), glideRequests, mediaRecords, this.listener));
}
private static class ThreadPhotoRailAdapter extends CursorRecyclerViewAdapter<ThreadPhotoRailAdapter.ThreadPhotoViewHolder> {
private static class ThreadPhotoRailAdapter extends RecyclerView.Adapter<ThreadPhotoRailAdapter.ThreadPhotoViewHolder> {
@SuppressWarnings("unused")
private static final String TAG = Log.tag(ThreadPhotoRailAdapter.class);
@@ -69,18 +71,27 @@ public class ThreadPhotoRailView extends FrameLayout {
@Nullable private OnItemClickedListener clickedListener;
private final List<MediaTable.MediaRecord> mediaRecords = new ArrayList<>();
private ThreadPhotoRailAdapter(@NonNull Context context,
@NonNull GlideRequests glideRequests,
@Nullable Cursor cursor,
@NonNull List<MediaTable.MediaRecord> mediaRecords,
@Nullable OnItemClickedListener listener)
{
super(context, cursor);
this.glideRequests = glideRequests;
this.clickedListener = listener;
this.mediaRecords.clear();
this.mediaRecords.addAll(mediaRecords);
}
@Override
public ThreadPhotoViewHolder onCreateItemViewHolder(ViewGroup parent, int viewType) {
public int getItemCount() {
return mediaRecords.size();
}
@Override
public @NonNull ThreadPhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View itemView = LayoutInflater.from(parent.getContext())
.inflate(R.layout.recipient_preference_photo_rail_item, parent, false);
@@ -88,18 +99,14 @@ public class ThreadPhotoRailView extends FrameLayout {
}
@Override
public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
ThumbnailView imageView = viewHolder.imageView;
MediaTable.MediaRecord mediaRecord = MediaTable.MediaRecord.from(cursor);
public void onBindViewHolder(@NonNull ThreadPhotoViewHolder viewHolder, int position) {
MediaTable.MediaRecord mediaRecord = mediaRecords.get(position);
Slide slide = MediaUtil.getSlideForAttachment(mediaRecord.getAttachment());
if (slide != null) {
imageView.setImageResource(glideRequests, slide, false, false);
}
imageView.setOnClickListener(v -> {
MediaPreviewCache.INSTANCE.setDrawable(imageView.getImageDrawable());
if (clickedListener != null) clickedListener.onItemClicked(imageView, mediaRecord);
viewHolder.imageView.setImageResource(glideRequests, slide, false, false);
viewHolder.imageView.setOnClickListener(v -> {
MediaPreviewCache.INSTANCE.setDrawable(viewHolder.imageView.getImageDrawable());
if (clickedListener != null) clickedListener.onItemClicked(viewHolder.imageView, mediaRecord);
});
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.recyclerview
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.recyclerview.widget.RecyclerView
/**
* Ignores all touch events, purely for rendering views in a recyclable manner.
*/
class NoTouchingRecyclerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(e: MotionEvent?): Boolean {
return false
}
override fun onInterceptTouchEvent(e: MotionEvent?): Boolean {
return false
}
}

View File

@@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.components.reminder
import android.content.Context
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.AccountValues.UsernameSyncState
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
@@ -8,17 +10,31 @@ import org.thoughtcrime.securesms.util.FeatureFlags
* Displays a reminder message when the local username gets out of sync with
* what the server thinks our username is.
*/
class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__something_went_wrong) {
class UsernameOutOfSyncReminder : Reminder(NO_RESOURCE) {
init {
val action = if (SignalStore.account().usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
R.id.reminder_action_fix_username_and_link
} else {
R.id.reminder_action_fix_username_link
}
addAction(
Action(
R.string.UsernameOutOfSyncReminder__fix_now,
R.id.reminder_action_fix_username
action
)
)
}
override fun getText(context: Context): CharSequence {
return if (SignalStore.account().usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
context.getString(R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
} else {
context.getString(R.string.UsernameOutOfSyncReminder__link_corrupt)
}
}
override fun isDismissable(): Boolean {
return false
}
@@ -26,7 +42,15 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s
companion object {
@JvmStatic
fun isEligible(): Boolean {
return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync
return if (FeatureFlags.usernames()) {
when (SignalStore.account().usernameSyncState) {
UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
UsernameSyncState.LINK_CORRUPTED -> true
UsernameSyncState.IN_SYNC -> false
}
} else {
false
}
}
}
}

View File

@@ -65,6 +65,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
)
StartLocation.PRIVACY -> AppSettingsFragmentDirections.actionDirectToPrivacy()
StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices()
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
}
}
@@ -188,6 +189,9 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
@JvmStatic
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, StartLocation.LINKED_DEVICES)
@JvmStatic
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.USERNAME_LINK)
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
return Intent(context, AppSettingsActivity::class.java)
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
@@ -209,7 +213,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
CREATE_NOTIFICATION_PROFILE(10),
NOTIFICATION_PROFILE_DETAILS(11),
PRIVACY(12),
LINKED_DEVICES(13);
LINKED_DEVICES(13),
USERNAME_LINK(14);
companion object {
fun fromCode(code: Int?): StartLocation {

View File

@@ -27,10 +27,12 @@ import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
import org.thoughtcrime.securesms.keyvalue.AccountValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.Util
@@ -232,6 +234,16 @@ class AppSettingsFragment : DSLSettingsFragment(
}
)
if (Environment.IS_NIGHTLY) {
clickPref(
title = DSLSettingsText.from("App updates"),
icon = DSLSettingsIcon.from(R.drawable.symbol_calendar_24),
onClick = {
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
}
)
}
dividerPref()
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
@@ -347,7 +359,7 @@ class AppSettingsFragment : DSLSettingsFragment(
summaryView.visibility = View.VISIBLE
avatarView.visibility = View.VISIBLE
if (FeatureFlags.usernames()) {
if (FeatureFlags.usernames() && SignalStore.account().usernameSyncState == AccountValues.UsernameSyncState.IN_SYNC) {
qrButton.visibility = View.VISIBLE
qrButton.isClickable = true
qrButton.setOnClickListener { model.onQrButtonClicked() }

View File

@@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.MegaphoneDatabase
@@ -48,13 +49,19 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Duration.Companion.seconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
@@ -153,6 +160,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Backup Playground"),
summary = DSLSettingsText.from("Test backup import/export."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToInternalBackupPlaygroundFragment())
}
)
switchPref(
title = DSLSettingsText.from("'Internal Details' button"),
summary = DSLSettingsText.from("Show a button in conversation settings that lets you see more information about a user."),
@@ -171,6 +186,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Clear all logs"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).logs.clearAll()
}) {
Toast.makeText(requireContext(), "Cleared all logs", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Clear keep longer logs"),
onClick = {
@@ -178,6 +204,28 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Clear all crashes"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).crashes.clear()
}) {
Toast.makeText(requireContext(), "Cleared crashes", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Clear all ANRs"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).anrs.clear()
}) {
Toast.makeText(requireContext(), "Cleared ANRs", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Log dump PreKey ServiceId-KeyIds"),
onClick = {
@@ -185,6 +233,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Retry all jobs now"),
summary = DSLSettingsText.from("Clear backoff intervals, app will restart"),
onClick = {
SimpleTask.run({
JobDatabase.getInstance(ApplicationDependencies.getApplication()).debugResetBackoffInterval()
}) {
AppUtil.restart(requireContext())
}
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Payments"))
@@ -439,9 +499,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
if (SignalStore.donationsValues().getSubscriber() != null) {
dividerPref()
switchPref(
title = DSLSettingsText.from("Disable LBRed"),
isChecked = state.callingDisableLBRed,
onClick = {
viewModel.setInternalCallingDisableLBRed(!state.callingDisableLBRed)
}
)
dividerPref()
if (SignalStore.donationsValues().getSubscriber() != null) {
sectionHeaderPref(DSLSettingsText.from("Badges"))
clickPref(
@@ -474,6 +542,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
}
)
dividerPref()
}
if (state.hasPendingOneTimeDonation) {
@@ -538,6 +608,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Add remote donate megaphone"),
onClick = {
viewModel.addRemoteDonateMegaphone()
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("CDS"))
@@ -639,6 +716,47 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Corrupt username"),
summary = DSLSettingsText.from("Changes our local username without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Corrupt your username?")
.setMessage("Are you sure? You might not be able to get your original username back.")
.setPositiveButton(android.R.string.ok) { _, _ ->
val random = "${(1..5).map { ('a'..'z').random() }.joinToString(separator = "") }.${Random.nextInt(1, 100)}"
SignalStore.account().username = random
SignalDatabase.recipients.setUsername(Recipient.self().id, random)
StorageSyncHelper.scheduleSyncForDataChange()
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
)
clickPref(
title = DSLSettingsText.from("Corrupt username link"),
summary = DSLSettingsText.from("Changes our local username link without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Corrupt your username link?")
.setMessage("Are you sure? You'll have to reset your link.")
.setPositiveButton(android.R.string.ok) { _, _ ->
SignalStore.account().usernameLink = UsernameLinkComponents(
entropy = Util.getSecretBytes(32),
serverId = SignalStore.account().usernameLink?.serverId ?: UUID.randomUUID()
)
StorageSyncHelper.scheduleSyncForDataChange()
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Chat Filters"))
clickPref(

View File

@@ -1,19 +1,24 @@
package org.thoughtcrime.securesms.components.settings.app.internal
import android.content.Context
import org.json.JSONObject
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
import org.thoughtcrime.securesms.database.model.addStyle
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.emoji.EmojiFiles
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
import org.thoughtcrime.securesms.jobs.FetchRemoteMegaphoneImageJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
import java.util.UUID
import kotlin.time.Duration.Companion.days
class InternalSettingsRepository(context: Context) {
@@ -58,4 +63,34 @@ class InternalSettingsRepository(context: Context) {
}
}
}
fun addRemoteDonateMegaphone() {
SignalExecutors.UNBOUNDED.execute {
val record = RemoteMegaphoneRecord(
uuid = UUID.randomUUID().toString(),
priority = 100,
countries = "*:1000000",
minimumVersion = 1,
doNotShowBefore = System.currentTimeMillis() - 2.days.inWholeMilliseconds,
doNotShowAfter = System.currentTimeMillis() + 28.days.inWholeMilliseconds,
showForNumberOfDays = 30,
conditionalId = null,
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
imageUrl = "/static/release-notes/donate-heart.png",
title = "Donate Test",
body = "Donate body test.",
primaryActionText = "Donate",
secondaryActionText = "Snooze",
primaryActionData = null,
secondaryActionData = JSONObject("{ \"snoozeDurationDays\": [5, 7, 100] }")
)
SignalDatabase.remoteMegaphones.insert(record)
if (record.imageUrl != null) {
ApplicationDependencies.getJobManager().add(FetchRemoteMegaphoneImageJob(record.uuid, record.imageUrl))
}
}
}
}

View File

@@ -15,6 +15,7 @@ data class InternalSettingsState(
val callingAudioProcessingMethod: CallManager.AudioProcessingMethod,
val callingDataMode: CallManager.DataMode,
val callingDisableTelecom: Boolean,
val callingDisableLBRed: Boolean,
val useBuiltInEmojiSet: Boolean,
val emojiVersion: EmojiFiles.Version?,
val removeSenderKeyMinimium: Boolean,

View File

@@ -113,6 +113,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setInternalCallingDisableLBRed(enabled: Boolean) {
preferenceDataStore.putBoolean(InternalValues.CALLING_DISABLE_LBRED, enabled)
refresh()
}
fun setUseConversationItemV2Media(enabled: Boolean) {
SignalStore.internalValues().setUseConversationItemV2Media(enabled)
refresh()
@@ -122,6 +127,10 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
repository.addSampleReleaseNote()
}
fun addRemoteDonateMegaphone() {
repository.addRemoteDonateMegaphone()
}
fun refresh() {
store.update { getState().copy(emojiVersion = it.emojiVersion) }
}
@@ -138,6 +147,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
callingAudioProcessingMethod = SignalStore.internalValues().callingAudioProcessingMethod(),
callingDataMode = SignalStore.internalValues().callingDataMode(),
callingDisableTelecom = SignalStore.internalValues().callingDisableTelecom(),
callingDisableLBRed = SignalStore.internalValues().callingDisableLBRed(),
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
emojiVersion = null,
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),

View File

@@ -0,0 +1,234 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import android.app.Activity.RESULT_OK
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.getLength
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
import org.thoughtcrime.securesms.compose.ComposeFragment
class InternalBackupPlaygroundFragment : ComposeFragment() {
private val viewModel: InternalBackupPlaygroundViewModel by viewModels()
private lateinit var exportFileLauncher: ActivityResultLauncher<Intent>
private lateinit var importFileLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
exportFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
requireContext().contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(viewModel.backupData!!)
Toast.makeText(requireContext(), "Saved successfully", Toast.LENGTH_SHORT).show()
} ?: Toast.makeText(requireContext(), "Failed to open output stream", Toast.LENGTH_SHORT).show()
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
importFileLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
result.data?.data?.let { uri ->
requireContext().contentResolver.getLength(uri)?.let { length ->
viewModel.import(length) { requireContext().contentResolver.openInputStream(uri)!! }
}
} ?: Toast.makeText(requireContext(), "No URI selected", Toast.LENGTH_SHORT).show()
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
Screen(
state = state,
onExportClicked = { viewModel.export() },
onImportMemoryClicked = { viewModel.import() },
onImportFileClicked = {
val intent = Intent().apply {
action = Intent.ACTION_GET_CONTENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
}
importFileLauncher.launch(intent)
},
onPlaintextClicked = { viewModel.onPlaintextToggled() },
onSaveToDiskClicked = {
val intent = Intent().apply {
action = Intent.ACTION_CREATE_DOCUMENT
type = "application/octet-stream"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
}
exportFileLauncher.launch(intent)
}
)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
}
}
@Composable
fun Screen(
state: ScreenState,
onExportClicked: () -> Unit = {},
onImportMemoryClicked: () -> Unit = {},
onImportFileClicked: () -> Unit = {},
onPlaintextClicked: () -> Unit = {},
onSaveToDiskClicked: () -> Unit = {}
) {
Surface {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
StateLabel(text = "Plaintext?")
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = state.plaintext,
onCheckedChange = { onPlaintextClicked() }
)
}
Spacer(modifier = Modifier.height(8.dp))
Buttons.LargePrimary(
onClick = onExportClicked,
enabled = !state.backupState.inProgress
) {
Text("Export")
}
Buttons.LargeTonal(
onClick = onImportMemoryClicked,
enabled = state.backupState == BackupState.EXPORT_DONE
) {
Text("Import from memory")
}
Buttons.LargeTonal(
onClick = onImportFileClicked
) {
Text("Import from file")
}
Spacer(modifier = Modifier.height(16.dp))
when (state.backupState) {
BackupState.NONE -> {
StateLabel("")
}
BackupState.EXPORT_IN_PROGRESS -> {
StateLabel("Export in progress...")
}
BackupState.EXPORT_DONE -> {
StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, or you can save it to a file.")
Spacer(modifier = Modifier.height(8.dp))
Buttons.MediumTonal(onClick = onSaveToDiskClicked) {
Text("Save to file")
}
}
BackupState.IMPORT_IN_PROGRESS -> {
StateLabel("Import in progress...")
}
}
}
}
}
@Composable
private fun StateLabel(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
textAlign = TextAlign.Center
)
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreen() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.NONE, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenExportInProgress() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.EXPORT_IN_PROGRESS, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenExportDone() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.EXPORT_DONE, plaintext = false))
}
}
}
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PreviewScreenImportInProgress() {
SignalTheme {
Surface {
Screen(state = ScreenState(backupState = BackupState.IMPORT_IN_PROGRESS, plaintext = false))
}
}
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.recipients.Recipient
import java.io.ByteArrayInputStream
import java.io.InputStream
class InternalBackupPlaygroundViewModel : ViewModel() {
var backupData: ByteArray? = null
val disposables = CompositeDisposable()
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, plaintext = false))
val state: State<ScreenState> = _state
fun export() {
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
disposables += Single.fromCallable { BackupRepository.export(plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { data ->
backupData = data
_state.value = _state.value.copy(backupState = BackupState.EXPORT_DONE)
}
}
fun import() {
backupData?.let {
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(it.size.toLong(), { ByteArrayInputStream(it) }, selfData, plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nothing ->
backupData = null
_state.value = _state.value.copy(backupState = BackupState.NONE)
}
}
}
fun import(length: Long, inputStreamFactory: () -> InputStream) {
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = plaintext) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { nothing ->
backupData = null
_state.value = _state.value.copy(backupState = BackupState.NONE)
}
}
fun onPlaintextToggled() {
_state.value = _state.value.copy(plaintext = !_state.value.plaintext)
}
override fun onCleared() {
disposables.clear()
}
data class ScreenState(
val backupState: BackupState,
val plaintext: Boolean
)
enum class BackupState(val inProgress: Boolean = false) {
NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, IMPORT_IN_PROGRESS(true)
}
}

View File

@@ -144,19 +144,21 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
private fun handleSubscriptionExpiration(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
SignalStore.donationsValues().clearUserManuallyCancelled()
handleSubscriptionPaymentFailure(state)
}
private fun handleSubscriptionPaymentFailure(state: InternalDonorErrorConfigurationState) {
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = true
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
state.selectedStripeDeclineCode?.let {
ActiveSubscription.ChargeFailure(
it.code,
"Test Charge Failure",
"Test Network Status",
"Test Network Reason",
it.code,
"Test"
)
}

View File

@@ -113,6 +113,7 @@ private fun DonationPendingBottomSheetContent(
text = stringResource(id = R.string.DonationPendingBottomSheet__donation_pending),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)

View File

@@ -17,24 +17,8 @@ import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.util.Currency
import kotlin.time.Duration.Companion.days
object DonationSerializationHelper {
private val PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT = 14.days
private val PENDING_ONE_TIME_NORMAL_TIMEOUT = 1.days
val PendingOneTimeDonation.isExpired: Boolean
get() {
val timeout = if (paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) {
PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT
} else {
PENDING_ONE_TIME_NORMAL_TIMEOUT
}
return (timestamp + timeout.inWholeMilliseconds) < System.currentTimeMillis()
}
fun createPendingOneTimeDonationProto(
badge: Badge,
paymentSourceType: PaymentSourceType,

View File

@@ -183,6 +183,7 @@ private fun DonationPaymentFailureBottomSheet(
text = stringResource(id = R.string.DonationErrorBottomSheet__donation_couldnt_be_processed),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
@@ -262,6 +263,7 @@ private fun DonationCompletedSheetContent(
text = stringResource(id = R.string.DonationCompletedBottomSheet__donation_complete),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
@@ -319,6 +321,7 @@ private fun DonationToggleRow(
) {
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)

View File

@@ -10,6 +10,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
@@ -35,7 +38,7 @@ class TerminalDonationDelegate(
for (donation in donations) {
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
TerminalDonationBottomSheet.show(fragmentManager, donation)
} else {
} else if (donation.error != null) {
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
@@ -45,5 +48,12 @@ class TerminalDonationDelegate(
}
}
}
val verifiedMonthlyDonation: Stripe3DSData? = SignalStore.donationsValues().consumeVerifiedSubscription3DSData()
if (verifiedMonthlyDonation != null) {
DonationPendingBottomSheet().apply {
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.gatewayRequest).build().toBundle()
}.show(fragmentManager, null)
}
}
}

View File

@@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.util.Projection
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Currency
/**
@@ -233,7 +234,6 @@ class DonateToSignalFragment :
customPref(
DonationPillToggle.Model(
isEnabled = state.areFieldsEnabled,
selected = state.donateToSignalType,
onClick = {
viewModel.toggleDonationType()
@@ -256,23 +256,27 @@ class DonateToSignalFragment :
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
isEnabled = state.canUpdate,
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__update_subscription_question)
.setMessage(
getString(
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
FiatMoneyUtil.format(
requireContext().resources,
viewModel.getSelectedSubscriptionCost(),
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
showDonationPendingDialog(state)
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__update_subscription_question)
.setMessage(
getString(
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
FiatMoneyUtil.format(
requireContext().resources,
viewModel.getSelectedSubscriptionCost(),
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
)
)
)
)
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
viewModel.updateSubscription()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
viewModel.updateSubscription()
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
}
}
)
@@ -282,28 +286,62 @@ class DonateToSignalFragment :
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
isEnabled = state.areFieldsEnabled,
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
viewModel.cancelSubscription()
}
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
.show()
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
showDonationPendingDialog(state)
} else {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
viewModel.cancelSubscription()
}
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
.show()
}
}
)
} else {
primaryButton(
text = DSLSettingsText.from(R.string.DonateToSignalFragment__continue),
isEnabled = state.canContinue,
isEnabled = state.continueEnabled,
onClick = {
viewModel.requestSelectGateway()
if (state.canContinue) {
viewModel.requestSelectGateway()
} else {
showDonationPendingDialog(state)
}
}
)
}
}
}
private fun showDonationPendingDialog(state: DonateToSignalState) {
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
}
} else {
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT) {
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
} else if (state.monthlyDonationState.nonVerifiedMonthlyDonation != null) {
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
} else {
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
}
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonateToSignalFragment__you_have_a_donation_pending)
.setMessage(message)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun DSLConfiguration.displayOneTimeSelection(areFieldsEnabled: Boolean, state: DonateToSignalState.OneTimeDonationState) {
when (state.donationStage) {
DonateToSignalState.DonationStage.INIT -> customPref(Boost.LoadingModel())
@@ -337,11 +375,6 @@ class DonateToSignalFragment :
}
private fun DSLConfiguration.displayMonthlySelection(areFieldsEnabled: Boolean, state: DonateToSignalState.MonthlyDonationState) {
if (state.transactionState.isTransactionJobPending) {
customPref(Subscription.LoaderModel())
return
}
when (state.donationStage) {
DonateToSignalState.DonationStage.INIT -> customPref(Subscription.LoaderModel())
DonateToSignalState.DonationStage.FAILURE -> customPref(NetworkFailure.Model { viewModel.retryMonthlyDonationState() })
@@ -437,10 +470,17 @@ class DonateToSignalFragment :
viewModel.refreshActiveSubscription()
}
override fun onUserCancelledPaymentFlow() {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
override fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney) {
val max = FiatMoneyUtil.format(resources, sepaEuroMaximum, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonateToSignal__donation_amount_too_high)
.setMessage(getString(R.string.DonateToSignalFragment__you_can_send_up_to_s_via_bank_transfer, max))
.setPositiveButton(android.R.string.ok, null)
.show()
}
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
}

View File

@@ -4,6 +4,10 @@ import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isLongRunning
import org.thoughtcrime.securesms.database.model.isPending
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.subscription.Subscription
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
@@ -19,8 +23,8 @@ data class DonateToSignalState(
val areFieldsEnabled: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY && !oneTimeDonationState.isOneTimeDonationPending
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
@@ -59,13 +63,20 @@ data class DonateToSignalState(
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canContinue: Boolean
val continueEnabled: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canContinue: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
DonateToSignalType.MONTHLY -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canUpdate: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false
@@ -85,9 +96,13 @@ data class DonateToSignalState(
val isCustomAmountFocused: Boolean = false,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = emptyList(),
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation() != null,
private val pendingOneTimeDonation: PendingOneTimeDonation? = null,
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
) {
val isOneTimeDonationPending: Boolean = pendingOneTimeDonation.isPending()
val isOneTimeDonationLongRunning: Boolean = pendingOneTimeDonation.isLongRunning()
val isNonVerifiedIdeal = pendingOneTimeDonation?.pendingVerification == true
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)
private val isCustomAmountTooSmall: Boolean = if (isCustomAmountFocused) customAmount.amount < minimumDonationAmountOfSelectedCurrency.amount else false
private val isCustomAmountZero: Boolean = customAmount.amount == BigDecimal.ZERO
@@ -103,6 +118,7 @@ data class DonateToSignalState(
val selectedSubscription: Subscription? = null,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = emptyList(),
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
val transactionState: TransactionState = TransactionState()
) {
val isSubscriptionActive: Boolean = _activeSubscription?.isActive == true

View File

@@ -13,14 +13,16 @@ import org.signal.core.util.StringUtil
import org.signal.core.util.logging.Log
import org.signal.core.util.money.FiatMoney
import org.signal.core.util.money.PlatformCurrencyUtil
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.isExpired
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
@@ -34,6 +36,7 @@ import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Currency
import java.util.Optional
/**
* Contains the logic to manage the UI of the unified donations screen.
@@ -208,24 +211,31 @@ class DonateToSignalViewModel(
}
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
val isOneTimeDonationInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true
JobTracker.JobState.RUNNING -> true
else -> false
}
}.orElse(false)
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
when (it) {
is DonationRedemptionJobStatus.PendingExternalVerification -> Optional.ofNullable(it.pendingOneTimeDonation)
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest,
DonationRedemptionJobStatus.FailedSubscription,
DonationRedemptionJobStatus.None -> Optional.empty()
}
}.distinctUntilChanged()
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { pending -> pending.filter { !it.isExpired }.isPresent }
val oneTimeDonationFromStore: Observable<Optional<PendingOneTimeDonation>> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { pending -> pending.filter { !it.isExpired } }
.distinctUntilChanged()
oneTimeDonationDisposables += Observable
.combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b }
.subscribe { hasPendingOneTimeDonation ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) }
.combineLatest(oneTimeDonationFromJob, oneTimeDonationFromStore) { job, store ->
if (store.isPresent) {
store
} else {
job
}
}
.subscribe { pendingOneTimeDonation: Optional<PendingOneTimeDonation> ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
}
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
@@ -295,23 +305,16 @@ class DonateToSignalViewModel(
}
private fun monitorLevelUpdateProcessing() {
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true
JobTracker.JobState.RUNNING -> true
else -> false
}
}.orElse(false)
}
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = DonationRedemptionJobWatcher.watchSubscriptionRedemption()
monthlyDonationDisposables += Observable
.combineLatest(isTransactionJobInProgress, LevelUpdate.isProcessing, DonateToSignalState::TransactionState)
.subscribeBy { transactionState ->
.combineLatest(redemptionJobStatus, LevelUpdate.isProcessing, ::Pair)
.subscribeBy { (jobStatus, levelUpdateProcessing) ->
store.update { state ->
state.copy(
monthlyDonationState = state.monthlyDonationState.copy(
transactionState = transactionState
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
)
)
}

View File

@@ -36,7 +36,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.math.BigDecimal
import java.util.Currency
/**
@@ -77,8 +79,12 @@ class DonationCheckoutDelegate(
registerGooglePayCallback()
fragment.setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle ->
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
handleGatewaySelectionResponse(response)
if (bundle.containsKey(GatewaySelectorBottomSheet.FAILURE_KEY)) {
callback.showSepaEuroMaximumDialog(FiatMoney(bundle.getSerializable(GatewaySelectorBottomSheet.SEPA_EURO_MAX) as BigDecimal, CurrencyUtil.EURO))
} else {
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
handleGatewaySelectionResponse(response)
}
}
fragment.setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
@@ -282,7 +288,7 @@ class DonationCheckoutDelegate(
if (throwable is DonationError.UserLaunchedExternalApplication) {
Log.d(TAG, "User launched an external application.", true)
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
return
}
@@ -330,7 +336,7 @@ class DonationCheckoutDelegate(
}
interface ErrorHandlerCallback {
fun onUserCancelledPaymentFlow()
fun onUserLaunchedAnExternalApplication()
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
}
@@ -342,5 +348,6 @@ class DonationCheckoutDelegate(
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney)
}
}

View File

@@ -15,14 +15,13 @@ object DonationPillToggle {
}
class Model(
val isEnabled: Boolean,
val selected: DonateToSignalType,
val onClick: () -> Unit
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean = true
override fun areContentsTheSame(newItem: Model): Boolean {
return isEnabled == newItem.isEnabled && selected == newItem.selected
return selected == newItem.selected
}
}

View File

@@ -20,7 +20,7 @@ data class CreditCardFormState(
number,
expiration.month.toInt(),
expiration.year.toInt(),
code.toInt()
code
)
}
}

View File

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Pa
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
/**
@@ -65,22 +66,24 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
presentTitleAndSubtitle(requireContext(), args.request)
space(66.dp)
space(16.dp)
if (state.loading) {
space(16.dp)
customPref(IndeterminateLoadingCircle)
space(16.dp)
return@configure
}
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
val isFirst = index == 0
space(16.dp)
when (gateway) {
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state, isFirst)
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state, isFirst)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state, isFirst)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state, isFirst)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state, isFirst)
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state)
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state)
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state)
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state)
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state)
}
}
@@ -88,12 +91,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState) {
if (state.isGooglePayAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
GooglePayButton.Model(
isEnabled = true,
@@ -107,12 +106,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState) {
if (state.isPayPalAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
PayPalButton.Model(
onClick = {
@@ -126,12 +121,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState) {
if (state.isCreditCardAvailable) {
if (!isFirstButton) {
space(8.dp)
}
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
@@ -144,30 +135,31 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState) {
if (state.isSEPADebitAvailable) {
if (!isFirstButton) {
space(8.dp)
}
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
if (state.sepaEuroMaximum != null &&
args.request.fiat.currency == CurrencyUtil.EURO &&
args.request.fiat.amount > state.sepaEuroMaximum.amount
) {
findNavController().popBackStack()
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
} else {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
}
)
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState) {
if (state.isIDEALAvailable) {
if (!isFirstButton) {
space(8.dp)
}
tonalButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
@@ -182,6 +174,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
companion object {
const val REQUEST_KEY = "payment_checkout_mode"
const val FAILURE_KEY = "gateway_failure"
const val SEPA_EURO_MAX = "sepa_euro_max"
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, request: GatewayRequest) {
when (request.donateToSignalType) {

View File

@@ -1,7 +1,9 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import io.reactivex.rxjava3.core.Single
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
import java.util.Locale
@@ -9,12 +11,12 @@ import java.util.Locale
class GatewaySelectorRepository(
private val donationsService: DonationsService
) {
fun getAvailableGateways(currencyCode: String): Single<Set<GatewayResponse.Gateway>> {
fun getAvailableGatewayConfiguration(currencyCode: String): Single<GatewayConfiguration> {
return Single.fromCallable {
donationsService.getDonationsConfiguration(Locale.getDefault())
}.flatMap { it.flattenResult() }
.map { configuration ->
configuration.getAvailablePaymentMethods(currencyCode).map {
val available = configuration.getAvailablePaymentMethods(currencyCode).map {
when (it) {
DonationsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
DonationsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
@@ -23,6 +25,16 @@ class GatewaySelectorRepository(
else -> listOf()
}
}.flatten().toSet()
GatewayConfiguration(
availableGateways = available,
sepaEuroMaximum = if (configuration.sepaMaximumEuros != null) FiatMoney(configuration.sepaMaximumEuros, CurrencyUtil.EURO) else null
)
}
}
data class GatewayConfiguration(
val availableGateways: Set<GatewayResponse.Gateway>,
val sepaEuroMaximum: FiatMoney?
)
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.models.Badge
data class GatewaySelectorState(
@@ -10,5 +11,6 @@ data class GatewaySelectorState(
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false,
val isIDEALAvailable: Boolean = false
val isIDEALAvailable: Boolean = false,
val sepaEuroMaximum: FiatMoney? = null
)

View File

@@ -36,17 +36,18 @@ class GatewaySelectorViewModel(
init {
val isGooglePayAvailable = repository.isGooglePayAvailable().toSingleDefault(true).onErrorReturnItem(false)
val availabilitySet = gatewaySelectorRepository.getAvailableGateways(currencyCode = args.request.currencyCode)
disposables += Single.zip(isGooglePayAvailable, availabilitySet, ::Pair).subscribeBy { (googlePayAvailable, gatewaysAvailable) ->
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.request.currencyCode)
disposables += Single.zip(isGooglePayAvailable, gatewayConfiguration, ::Pair).subscribeBy { (googlePayAvailable, gatewayConfiguration) ->
SignalStore.donationsValues().isGooglePayReady = googlePayAvailable
store.update {
it.copy(
loading = false,
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.IDEAL)
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.CREDIT_CARD),
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.GOOGLE_PAY),
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.PAYPAL),
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.IDEAL),
sepaEuroMaximum = gatewayConfiguration.sepaEuroMaximum
)
}
}

View File

@@ -29,6 +29,9 @@ data class Stripe3DSData(
@IgnoredOnParcel
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
@IgnoredOnParcel
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY && paymentSourceType.isBankTransfer)
fun toProtoBytes(): ByteArray {
return ExternalLaunchTransactionState(
stripeIntentAccessor = ExternalLaunchTransactionState.StripeIntentAccessor(

View File

@@ -5,23 +5,29 @@ import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.activity.ComponentDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.navArgs
import com.google.android.material.button.MaterialButton
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.visible
/**
@@ -35,6 +41,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
}
val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
it.webView.webViewClient = WebViewClient()
it.webView.clearCache(true)
it.webView.clearHistory()
}
@@ -48,7 +55,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@SuppressLint("SetJavaScriptEnabled")
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog!!.window!!.setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
@@ -68,6 +75,19 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
binding.webView
)
)
if (FeatureFlags.internalUser() && args.stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
val openApp = MaterialButton(requireContext()).apply {
text = "Open App"
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
}
setOnClickListener {
handleLaunchExternal(Intent(Intent.ACTION_VIEW, args.uri))
}
}
binding.root.addView(openApp)
}
}
override fun onDismiss(dialog: DialogInterface) {
@@ -78,14 +98,13 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
}
private fun handleLaunchExternal(intent: Intent) {
startActivity(intent)
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
result = bundleOf(
LAUNCHED_EXTERNAL to true
)
startActivity(intent)
dismissAllowingStateLoss()
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
object BankDetailsValidator {
private val EMAIL_REGEX: Regex = ".+@.+\\..+".toRegex()
fun validName(name: String): Boolean {
return name.length >= 2
}
fun validEmail(email: String): Boolean {
return email.length >= 3 && email.matches(EMAIL_REGEX)
}
}

View File

@@ -43,6 +43,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
@@ -131,7 +134,7 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
setDisplayFindAccountInfoSheet = viewModel::setDisplayFindAccountInfoSheet,
onLearnMoreClick = this::onLearnMoreClick,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
onFocusChanged = viewModel::onFocusChanged,
donateLabel = donateLabel
)
}
@@ -156,13 +159,15 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
}
})
}
}
@@ -182,7 +187,7 @@ private fun BankTransferDetailsContentPreview() {
setDisplayFindAccountInfoSheet = {},
onLearnMoreClick = {},
onDonateClick = {},
onIBANFocusChanged = {},
onFocusChanged = { _, _ -> },
donateLabel = "Donate $5/month"
)
}
@@ -198,7 +203,7 @@ private fun BankTransferDetailsContent(
setDisplayFindAccountInfoSheet: (Boolean) -> Unit,
onLearnMoreClick: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
onFocusChanged: (Field, Boolean) -> Unit,
donateLabel: String
) {
Scaffolds.Settings(
@@ -270,7 +275,8 @@ private fun BankTransferDetailsContent(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.IBAN, it.hasFocus) }
.focusRequester(focusRequester)
)
}
@@ -289,9 +295,17 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
)
}
@@ -309,16 +323,26 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
isError = state.showEmailError(),
supportingText = {
if (state.showEmailError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__invalid_email_address))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
TextButton(
onClick = { setDisplayFindAccountInfoSheet(true) }
@@ -334,7 +358,7 @@ private fun BankTransferDetailsContent(
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
.padding(vertical = 16.dp)
) {
Text(text = donateLabel)
}

View File

@@ -6,15 +6,26 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankDetailsValidator
data class BankTransferDetailsState(
val name: String = "",
val nameFocusState: FocusState = FocusState.NOT_FOCUSED,
val iban: String = "",
val email: String = "",
val emailFocusState: FocusState = FocusState.NOT_FOCUSED,
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID,
val displayFindAccountInfoSheet: Boolean = false
) {
val canProceed = name.isNotBlank() && email.isNotBlank() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
val canProceed = BankDetailsValidator.validName(name) && BankDetailsValidator.validEmail(email) && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
fun showNameError(): Boolean {
return nameFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validName(name)
}
fun showEmailError(): Boolean {
return emailFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validEmail(email)
}
fun asSEPADebitData(): StripeApi.SEPADebitData {
return StripeApi.SEPADebitData(
@@ -23,4 +34,10 @@ data class BankTransferDetailsState(
email = email.trim()
)
}
enum class FocusState {
NOT_FOCUSED,
FOCUSED,
LOST_FOCUS
}
}

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.t
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details.BankTransferDetailsState.FocusState
class BankTransferDetailsViewModel : ViewModel() {
@@ -30,10 +31,30 @@ class BankTransferDetailsViewModel : ViewModel() {
)
}
fun onIBANFocusChanged(isFocused: Boolean) {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
fun onFocusChanged(field: Field, isFocused: Boolean) {
when (field) {
Field.IBAN -> {
internalState.value = internalState.value.copy(
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
)
}
Field.NAME -> {
if (isFocused && internalState.value.nameFocusState == FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = FocusState.FOCUSED)
} else if (!isFocused && internalState.value.nameFocusState == FocusState.FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = FocusState.LOST_FOCUS)
}
}
Field.EMAIL -> {
if (isFocused && internalState.value.emailFocusState == FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = FocusState.FOCUSED)
} else if (!isFocused && internalState.value.emailFocusState == FocusState.FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = FocusState.LOST_FOCUS)
}
}
}
}
fun onIBANChanged(iban: String) {
@@ -48,4 +69,10 @@ class BankTransferDetailsViewModel : ViewModel() {
email = email
)
}
enum class Field {
IBAN,
NAME,
EMAIL
}
}

View File

@@ -28,7 +28,7 @@ enum class IdealBank(
REVOLUT("revolut"),
SNS_BANK("sns_bank"),
TRIODOS_BANK("triodos_bank"),
VAN_LANCHOT("van_lanchot"),
VAN_LANSCHOT("van_lanschot"),
YOURSAFE("yoursafe");
fun getUIValues(): UIValues = bankToUIValues[this]!!
@@ -83,8 +83,8 @@ enum class IdealBank(
name = R.string.IdealBank__triodos_bank,
icon = R.drawable.ideal_triodos_bank
),
VAN_LANCHOT to UIValues(
name = R.string.IdealBank__van_lanchot,
VAN_LANSCHOT to UIValues(
name = R.string.IdealBank__van_lanschot,
icon = R.drawable.ideal_van_lanschot
),
YOURSAFE to UIValues(

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.t
import android.os.Bundle
import android.view.View
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -28,6 +29,8 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -41,7 +44,8 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
@@ -60,12 +64,14 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal.IdealTransferDetailsViewModel.Field
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment for inputting necessary bank transfer information for iDEAL donation
@@ -73,7 +79,9 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
private val args: IdealTransferDetailsFragmentArgs by navArgs()
private val viewModel: IdealTransferDetailsViewModel by viewModels()
private val viewModel: IdealTransferDetailsViewModel by viewModel {
IdealTransferDetailsViewModel(args.request.donateToSignalType == DonateToSignalType.MONTHLY)
}
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
@@ -125,14 +133,24 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
}
}
val idealDirections = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
R.string.IdealTransferDetailsFragment__enter_your_bank
} else {
R.string.IdealTransferDetailsFragment__enter_your_bank_details_one_time
}
}
IdealTransferDetailsContent(
state = state,
idealDirections = idealDirections,
donateLabel = donateLabel,
onNavigationClick = { findNavController().popBackStack() },
onLearnMoreClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()) },
onSelectBankClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionIdealTransferDetailsFragmentToIdealTransferBankSelectionDialogFragment()) },
onNameChanged = viewModel::onNameChanged,
onEmailChanged = viewModel::onEmailChanged,
onFocusChanged = viewModel::onFocusChanged,
onDonateClick = this::onDonateClick
)
}
@@ -147,13 +165,21 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun onUserLaunchedAnExternalApplication() {
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, true)
}
})
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
}
})
}
}
@@ -161,13 +187,15 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
@Composable
private fun IdealTransferDetailsContentPreview() {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(),
state = IdealTransferDetailsState(isMonthly = true),
idealDirections = R.string.IdealTransferDetailsFragment__enter_your_bank,
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onFocusChanged = { _, _ -> },
onDonateClick = {}
)
}
@@ -175,12 +203,14 @@ private fun IdealTransferDetailsContentPreview() {
@Composable
private fun IdealTransferDetailsContent(
state: IdealTransferDetailsState,
@StringRes idealDirections: Int,
donateLabel: String,
onNavigationClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSelectBankClick: () -> Unit,
onNameChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFocusChanged: (Field, Boolean) -> Unit,
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
@@ -201,7 +231,7 @@ private fun IdealTransferDetailsContent(
) {
item {
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
val fullString = stringResource(id = idealDirections, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
@@ -221,7 +251,6 @@ private fun IdealTransferDetailsContent(
onSelectBankClick = onSelectBankClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
@@ -239,34 +268,52 @@ private fun IdealTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
isError = state.showNameError(),
supportingText = {
if (state.showNameError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__minimum_2_characters))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.NAME, it.hasFocus) }
)
}
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canProceed()) {
onDonateClick()
if (state.isMonthly) {
item {
TextField(
value = state.email,
onValueChange = onEmailChanged,
label = {
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
if (state.canProceed()) {
onDonateClick()
}
}
}
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
),
isError = state.showEmailError(),
supportingText = {
if (state.showEmailError()) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__invalid_email_address))
}
},
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onFocusChanged(Field.EMAIL, it.hasFocus) }
)
}
}
}
@@ -311,6 +358,7 @@ private fun IdealBankSelector(
Image(
painter = painterResource(id = uiValues?.icon ?: R.drawable.bank_transfer),
contentDescription = null,
colorFilter = if (uiValues?.icon == null) ColorFilter.tint(MaterialTheme.colorScheme.onSurface) else null,
modifier = Modifier
.padding(start = 16.dp, end = 12.dp)
.size(32.dp)
@@ -329,7 +377,9 @@ private fun IdealBankSelector(
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
),
supportingText = {},
modifier = modifier
.defaultMinSize(minHeight = 78.dp)
.clickable(
onClick = onSelectBankClick,
role = Role.Button

View File

@@ -6,12 +6,25 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankDetailsValidator
data class IdealTransferDetailsState(
val isMonthly: Boolean,
val idealBank: IdealBank? = null,
val name: String = "",
val email: String = ""
val nameFocusState: FocusState = FocusState.NOT_FOCUSED,
val email: String = "",
val emailFocusState: FocusState = FocusState.NOT_FOCUSED
) {
fun showNameError(): Boolean {
return nameFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validName(name)
}
fun showEmailError(): Boolean {
return emailFocusState == FocusState.LOST_FOCUS && !BankDetailsValidator.validEmail(email)
}
fun asIDEALData(): StripeApi.IDEALData {
return StripeApi.IDEALData(
bank = idealBank!!.code,
@@ -21,6 +34,12 @@ data class IdealTransferDetailsState(
}
fun canProceed(): Boolean {
return idealBank != null && name.isNotBlank() && email.isNotBlank()
return idealBank != null && BankDetailsValidator.validName(name) && (!isMonthly || BankDetailsValidator.validEmail(email))
}
enum class FocusState {
NOT_FOCUSED,
FOCUSED,
LOST_FOCUS
}
}

View File

@@ -9,9 +9,9 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class IdealTransferDetailsViewModel : ViewModel() {
class IdealTransferDetailsViewModel(isMonthly: Boolean) : ViewModel() {
private val internalState = mutableStateOf(IdealTransferDetailsState())
private val internalState = mutableStateOf(IdealTransferDetailsState(isMonthly = isMonthly))
var state: State<IdealTransferDetailsState> = internalState
fun onNameChanged(name: String) {
@@ -26,9 +26,34 @@ class IdealTransferDetailsViewModel : ViewModel() {
)
}
fun onFocusChanged(field: Field, isFocused: Boolean) {
when (field) {
Field.NAME -> {
if (isFocused && internalState.value.nameFocusState == IdealTransferDetailsState.FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = IdealTransferDetailsState.FocusState.FOCUSED)
} else if (!isFocused && internalState.value.nameFocusState == IdealTransferDetailsState.FocusState.FOCUSED) {
internalState.value = internalState.value.copy(nameFocusState = IdealTransferDetailsState.FocusState.LOST_FOCUS)
}
}
Field.EMAIL -> {
if (isFocused && internalState.value.emailFocusState == IdealTransferDetailsState.FocusState.NOT_FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = IdealTransferDetailsState.FocusState.FOCUSED)
} else if (!isFocused && internalState.value.emailFocusState == IdealTransferDetailsState.FocusState.FOCUSED) {
internalState.value = internalState.value.copy(emailFocusState = IdealTransferDetailsState.FocusState.LOST_FOCUS)
}
}
}
}
fun onBankSelected(idealBank: IdealBank) {
internalState.value = internalState.value.copy(
idealBank = idealBank
)
}
enum class Field {
NAME,
EMAIL
}
}

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import androidx.compose.animation.AnimatedVisibility
@@ -12,41 +13,54 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.launch
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.viewModel
@@ -61,15 +75,15 @@ class BankTransferMandateFragment : ComposeFragment() {
BankTransferMandateViewModel(PaymentSourceType.Stripe.SEPADebit)
}
private lateinit var statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection
private lateinit var statusBarColorAnimator: StatusBarColorAnimator
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
statusBarColorNestedScrollConnection = StatusBarColorNestedScrollConnection(requireActivity())
statusBarColorAnimator = StatusBarColorAnimator(requireActivity())
}
override fun onResume() {
super.onResume()
statusBarColorNestedScrollConnection.setColorImmediate()
statusBarColorAnimator.setColorImmediate()
}
@Composable
@@ -83,7 +97,7 @@ class BankTransferMandateFragment : ComposeFragment() {
onNavigationClick = this::onNavigationClick,
onContinueClick = this::onContinueClick,
onLearnMoreClick = this::onLearnMoreClick,
modifier = Modifier.nestedScroll(statusBarColorNestedScrollConnection)
onCanScrollUp = statusBarColorAnimator::setCanScrollUp
)
}
@@ -119,11 +133,14 @@ fun BankTransferScreenPreview() {
failedToLoadMandate = false,
onNavigationClick = {},
onContinueClick = {},
onLearnMoreClick = {}
onLearnMoreClick = {},
onCanScrollUp = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun BankTransferScreen(
bankMandate: String,
@@ -131,91 +148,126 @@ fun BankTransferScreen(
onNavigationClick: () -> Unit,
onContinueClick: () -> Unit,
onLearnMoreClick: () -> Unit,
modifier: Modifier = Modifier
onCanScrollUp: (Boolean) -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
onNavigationClick = onNavigationClick,
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24)),
titleContent = { contentOffset, title ->
AnimatedVisibility(
visible = contentOffset < 0f,
enter = fadeIn(),
exit = fadeOut()
) {
Text(text = title, style = MaterialTheme.typography.titleLarge)
}
},
modifier = modifier
) {
LazyColumn(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(top = 64.dp)
) {
item {
Image(
painter = painterResource(id = R.drawable.bank_transfer),
contentScale = ContentScale.Inside,
contentDescription = null,
modifier = Modifier
.size(72.dp)
.background(
SignalTheme.colors.colorSurface2,
CircleShape
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
Scaffold(
topBar = {
TopAppBar(
title = {
AnimatedVisibility(
visible = listState.canScrollBackward,
enter = fadeIn(),
exit = fadeOut()
) {
Text(text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer), style = MaterialTheme.typography.titleLarge)
}
},
navigationIcon = {
IconButton(
onClick = onNavigationClick,
Modifier.padding(end = 16.dp)
) {
Icon(
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24)),
contentDescription = null
)
)
}
}
},
colors = if (listState.canScrollBackward) TopAppBarDefaults.topAppBarColors(containerColor = SignalTheme.colors.colorSurface2) else TopAppBarDefaults.topAppBarColors()
)
}
) {
onCanScrollUp(listState.canScrollBackward)
item {
Text(
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
)
}
Column(horizontalAlignment = CenterHorizontally, modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f, true)
.padding(top = 64.dp)
) {
item {
Image(
painter = painterResource(id = R.drawable.bank_transfer),
contentScale = ContentScale.FillBounds,
contentDescription = null,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
modifier = Modifier
.size(72.dp)
.background(
SignalTheme.colors.colorSurface2,
CircleShape
)
.padding(18.dp)
)
}
item {
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
item {
Text(
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
)
}
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier
.padding(bottom = 12.dp)
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
)
}
item {
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
item {
Dividers.Default()
}
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, ""),
onUrlClick = {
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
),
modifier = Modifier
.padding(bottom = 12.dp)
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
)
}
item {
Text(
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
)
item {
Dividers.Default()
}
item {
Text(
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
)
}
}
if (!failedToLoadMandate) {
item {
Surface(
shadowElevation = if (listState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Buttons.LargeTonal(
onClick = onContinueClick,
onClick = {
if (!listState.canScrollForward) {
onContinueClick()
} else {
scope.launch {
listState.animateScrollBy(value = 1000f)
}
}
},
modifier = Modifier
.padding(top = 16.dp, bottom = 46.dp)
.wrapContentWidth()
.padding(top = 16.dp, bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__agree))
}
}
}

View File

@@ -31,17 +31,17 @@ fun StripeFailureCode.mapToErrorStringResource(): Int {
fun StripeDeclineCode.mapToErrorStringResource(): Int {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> R.string.DeclineCode__your_card_does_not_have_sufficient_funds
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> R.string.DeclineCode__try_completing_the_payment_again
StripeDeclineCode.Code.PROCESSING_ERROR -> R.string.DeclineCode__try_again
StripeDeclineCode.Code.REENTER_TRANSACTION -> R.string.DeclineCode__try_again
@@ -50,26 +50,3 @@ fun StripeDeclineCode.mapToErrorStringResource(): Int {
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
}
}
fun StripeDeclineCode.shouldRouteToGooglePay(): Boolean {
return when (this) {
is StripeDeclineCode.Known -> when (this.code) {
StripeDeclineCode.Code.APPROVE_WITH_ID -> true
StripeDeclineCode.Code.CALL_ISSUER -> true
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> false
StripeDeclineCode.Code.EXPIRED_CARD -> true
StripeDeclineCode.Code.INCORRECT_NUMBER -> true
StripeDeclineCode.Code.INCORRECT_CVC -> true
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> false
StripeDeclineCode.Code.INVALID_CVC -> true
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> true
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> true
StripeDeclineCode.Code.INVALID_NUMBER -> true
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> false
StripeDeclineCode.Code.PROCESSING_ERROR -> false
StripeDeclineCode.Code.REENTER_TRANSACTION -> false
else -> false
}
else -> false
}
}

View File

@@ -32,7 +32,7 @@ object ActiveSubscriptionPreference {
val subscription: Subscription,
val renewalTimestamp: Long = -1L,
val redemptionState: ManageDonationsState.RedemptionState,
val activeSubscription: ActiveSubscription.Subscription,
val activeSubscription: ActiveSubscription.Subscription?,
val onContactSupport: () -> Unit,
val onPendingClick: (FiatMoney) -> Unit
) : PreferenceModel<Model>() {
@@ -104,7 +104,7 @@ object ActiveSubscriptionPreference {
}
private fun presentFailureState(model: Model) {
if (model.activeSubscription.isFailedPayment || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
if (model.activeSubscription?.isFailedPayment == true || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
presentPaymentFailureState(model)
} else {
presentRedemptionFailureState(model)

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
/**
* Represent the status of a donation as represented in the job system.
*/
sealed class DonationRedemptionJobStatus {
/**
* No pending/running jobs for a donation type.
*/
object None : DonationRedemptionJobStatus()
/**
* Donation is pending external user verification (e.g., iDEAL).
*
* For one-time, pending donation data is provided via the job data as it is not in the store yet.
*/
class PendingExternalVerification(
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null
) : DonationRedemptionJobStatus()
/**
* Donation is at the receipt request status.
*
* For one-time donations, pending donation data available via the store.
*/
object PendingReceiptRequest : DonationRedemptionJobStatus()
/**
* Donation is at the receipt redemption status.
*
* For one-time donations, pending donation data available via the store.
*/
object PendingReceiptRedemption : DonationRedemptionJobStatus()
/**
* Representation of a failed subscription job chain derived from no pending/running jobs and
* a failure state in the store.
*/
object FailedSubscription : DonationRedemptionJobStatus()
fun isInProgress(): Boolean {
return when (this) {
is PendingExternalVerification,
PendingReceiptRedemption,
PendingReceiptRequest -> true
FailedSubscription,
None -> false
}
}
}

View File

@@ -1,14 +1,17 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import java.util.Optional
import java.util.concurrent.TimeUnit
/**
@@ -21,22 +24,44 @@ object DonationRedemptionJobWatcher {
ONE_TIME
}
fun watchSubscriptionRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.SUBSCRIPTION)
@JvmStatic
@WorkerThread
fun hasPendingRedemptionJob(): Boolean {
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION).isInProgress() || getDonationRedemptionJobStatus(RedemptionType.ONE_TIME).isInProgress()
}
fun watchOneTimeRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.ONE_TIME)
fun watchSubscriptionRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.SUBSCRIPTION)
private fun watch(redemptionType: RedemptionType): Observable<Optional<JobTracker.JobState>> = Observable.interval(0, 5, TimeUnit.SECONDS).map {
@JvmStatic
@WorkerThread
fun getSubscriptionRedemptionJobStatus(): DonationRedemptionJobStatus {
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION)
}
fun watchOneTimeRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.ONE_TIME)
private fun watch(redemptionType: RedemptionType): Observable<DonationRedemptionJobStatus> {
return Observable
.interval(0, 5, TimeUnit.SECONDS)
.map {
getDonationRedemptionJobStatus(redemptionType)
}
.distinctUntilChanged()
}
private fun getDonationRedemptionJobStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
val queue = when (redemptionType) {
RedemptionType.SUBSCRIPTION -> DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE
}
val externalLaunchJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == ExternalLaunchDonationJob.KEY && it.parameters.queue?.startsWith(queue) == true
}
val donationJobSpecs = ApplicationDependencies
.getJobManager()
.find { it.queueKey?.startsWith(queue) == true }
.sortedBy { it.createTime }
val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue?.startsWith(queue) == true
val externalLaunchJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == ExternalLaunchDonationJob.KEY
}
val receiptRequestJobKey = when (redemptionType) {
@@ -44,16 +69,70 @@ object DonationRedemptionJobWatcher {
RedemptionType.ONE_TIME -> BoostReceiptRequestResponseJob.KEY
}
val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
it.factoryKey == receiptRequestJobKey && it.parameters.queue?.startsWith(queue) == true
val receiptJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == receiptRequestJobKey
}
val jobState: JobTracker.JobState? = externalLaunchJobState ?: redemptionJobState ?: receiptJobState
val redemptionJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
it.factoryKey == DonationReceiptRedemptionJob.KEY
}
if (redemptionType == RedemptionType.SUBSCRIPTION && jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
Optional.of(JobTracker.JobState.FAILURE)
val jobSpec: JobSpec? = externalLaunchJobSpec ?: redemptionJobSpec ?: receiptJobSpec
return if (redemptionType == RedemptionType.SUBSCRIPTION && jobSpec == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
DonationRedemptionJobStatus.FailedSubscription
} else {
Optional.ofNullable(jobState)
jobSpec?.toDonationRedemptionStatus(redemptionType) ?: DonationRedemptionJobStatus.None
}
}.distinctUntilChanged()
}
private fun JobSpec.toDonationRedemptionStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
return when (factoryKey) {
ExternalLaunchDonationJob.KEY -> {
val stripe3DSData = ExternalLaunchDonationJob.Factory.parseSerializedData(serializedData!!)
DonationRedemptionJobStatus.PendingExternalVerification(
pendingOneTimeDonation = pendingOneTimeDonation(redemptionType, stripe3DSData),
nonVerifiedMonthlyDonation = nonVerifiedMonthlyDonation(redemptionType, stripe3DSData)
)
}
SubscriptionReceiptRequestResponseJob.KEY,
BoostReceiptRequestResponseJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRequest
DonationReceiptRedemptionJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRedemption
else -> {
DonationRedemptionJobStatus.None
}
}
}
private fun JobSpec.pendingOneTimeDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): PendingOneTimeDonation? {
if (redemptionType != RedemptionType.ONE_TIME) {
return null
}
return DonationSerializationHelper.createPendingOneTimeDonationProto(
badge = stripe3DSData.gatewayRequest.badge,
paymentSourceType = stripe3DSData.paymentSourceType,
amount = stripe3DSData.gatewayRequest.fiat
).copy(
timestamp = createTime,
pendingVerification = true,
checkedVerification = runAttempt > 0
)
}
private fun JobSpec.nonVerifiedMonthlyDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): NonVerifiedMonthlyDonation? {
if (redemptionType != RedemptionType.SUBSCRIPTION) {
return null
}
return NonVerifiedMonthlyDonation(
timestamp = createTime,
price = stripe3DSData.gatewayRequest.fiat,
level = stripe3DSData.gatewayRequest.level.toInt(),
checkedVerification = runAttempt > 0
)
}
}

View File

@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
@@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.help.HelpFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -51,6 +53,11 @@ class ManageDonationsFragment :
),
ExpiredGiftSheet.Callback {
companion object {
private val alertedIdealDonations = mutableSetOf<Long>()
const val DONATE_TROUBLESHOOTING_URL = "https://support.signal.org/hc/articles/360031949872#fix"
}
private val supportTechSummary: CharSequence by lazy {
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
.append(" ")
@@ -92,6 +99,33 @@ class ManageDonationsFragment :
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
if (state.nonVerifiedMonthlyDonation?.checkedVerification == true &&
!alertedIdealDonations.contains(state.nonVerifiedMonthlyDonation.timestamp)
) {
alertedIdealDonations += state.nonVerifiedMonthlyDonation.timestamp
val amount = FiatMoneyUtil.format(resources, state.nonVerifiedMonthlyDonation.price)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
} else if (state.pendingOneTimeDonation?.pendingVerification == true &&
state.pendingOneTimeDonation.checkedVerification &&
!alertedIdealDonations.contains(state.pendingOneTimeDonation.timestamp)
) {
alertedIdealDonations += state.pendingOneTimeDonation.timestamp
val amount = FiatMoneyUtil.format(resources, state.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed, amount))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
@@ -149,7 +183,14 @@ class ManageDonationsFragment :
} else {
customPref(IndeterminateLoadingCircle)
}
} else if (state.hasOneTimeBadge) {
} else if (state.nonVerifiedMonthlyDonation != null) {
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == state.nonVerifiedMonthlyDonation.level }
if (subscription != null) {
presentNonVerifiedSubscriptionSettings(state.nonVerifiedMonthlyDonation, subscription, state)
} else {
customPref(IndeterminateLoadingCircle)
}
} else if (state.hasOneTimeBadge || state.pendingOneTimeDonation != null) {
presentActiveOneTimeDonorSettings(state)
} else {
presentNotADonorSettings(state.hasReceipts)
@@ -186,7 +227,7 @@ class ManageDonationsFragment :
displayPendingDialog(it)
},
onErrorClick = {
displayPendingOneTimeDonationErrorDialog(it)
displayPendingOneTimeDonationErrorDialog(it, pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.IDEAL)
}
)
)
@@ -241,6 +282,25 @@ class ManageDonationsFragment :
}
}
private fun DSLConfiguration.presentNonVerifiedSubscriptionSettings(
nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation,
subscription: Subscription,
state: ManageDonationsState
) {
presentSubscriptionSettingsWithState(state) {
customPref(
ActiveSubscriptionPreference.Model(
price = nonVerifiedMonthlyDonation.price,
subscription = subscription,
redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS,
onContactSupport = {},
activeSubscription = null,
onPendingClick = {}
)
)
}
}
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
state: ManageDonationsState,
subscriptionBlock: DSLConfiguration.() -> Unit
@@ -321,7 +381,7 @@ class ManageDonationsFragment :
externalLinkPref(
title = DSLSettingsText.from(R.string.ManageDonationsFragment__subscription_faq),
icon = DSLSettingsIcon.from(R.drawable.symbol_help_24),
linkId = R.string.donate_url
linkId = R.string.donate_faq_url
)
}
@@ -344,14 +404,14 @@ class ManageDonationsFragment :
.show()
}
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue) {
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue, isIdeal: Boolean) {
when (error.type) {
DonationErrorValue.Type.REDEMPTION -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
.setMessage(R.string.DonationsErrors__your_badge_could_not)
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
CommunicationActions.openBrowserLink(requireContext(), DONATE_TROUBLESHOOTING_URL)
}
.setPositiveButton(R.string.Subscription__contact_support) { _, _ ->
requireActivity().finish()
@@ -363,11 +423,17 @@ class ManageDonationsFragment :
.show()
}
else -> {
val message = if (isIdeal) {
R.string.DonationsErrors__your_ideal_couldnt_be_processed
} else {
R.string.DonationsErrors__try_another_payment_method
}
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DonationsErrors__error_processing_payment)
.setMessage(R.string.DonationsErrors__try_another_payment_method)
.setMessage(message)
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
CommunicationActions.openBrowserLink(requireContext(), DONATE_TROUBLESHOOTING_URL)
}
.setPositiveButton(android.R.string.ok, null)
.setOnDismissListener {

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