Compare commits

..

142 Commits

Author SHA1 Message Date
Greyson Parrelli
1a2482c52d Bump version to 6.39.3 2023-11-11 12:37:37 -05:00
Greyson Parrelli
a715192844 Bump version to 6.39.2 2023-11-11 12:03:31 -05:00
Greyson Parrelli
7dc4661fb1 Fix shared group membership check. 2023-11-11 12:02:49 -05: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
Nicholas Tinsley
f7690245aa Bump version to 6.38.0 2023-10-25 15:51:26 -04:00
Nicholas Tinsley
f44e32fd6a Update translations and other static files. 2023-10-25 15:50:48 -04:00
Nicholas Tinsley
8bac34238e Prevent crash on reaction animation end. 2023-10-25 15:44:13 -04:00
Nicholas Tinsley
6d2f6ce2f9 Hide safety verification in bottom sheet for null senders. 2023-10-25 15:44:13 -04:00
Alex Hart
3a465cc56b Account for horizontal padding when calculating available footer space. 2023-10-25 15:44:13 -04:00
Greyson Parrelli
617369dbc0 Make type a mandatory param on IncomingMessage. 2023-10-25 15:44:13 -04:00
Alex Hart
c0fed1498e Utilze visibility instead of isVisible for restoration of view visibility after long press. 2023-10-25 15:44:13 -04:00
Alex Hart
5bdd3ce47a Add background to sticky year header for donation receipts. 2023-10-25 14:30:23 -04:00
Greyson Parrelli
6b3f41d675 Merge IncomingTextMessages into IncomingMessage. 2023-10-25 14:30:23 -04:00
Alex Hart
23b696c9cf Rotate ideal and sepa flags. 2023-10-25 14:30:23 -04:00
Alex Hart
079400f89e Donation error sheet wiring and UI. 2023-10-25 14:30:23 -04:00
Alex Hart
e12d467627 Add ordering strategy for netherlands donation gateways. 2023-10-25 14:30:23 -04:00
Alex Hart
162ca3e21e Add locale based feature flags for iDEAL / SEPA donations. 2023-10-25 14:30:23 -04:00
Alex Hart
dddd0e7b71 Pipe in bank mandate parameter. 2023-10-25 14:30:23 -04:00
Cody Henthorne
95d68e09da Cycle hide contacts remote config. 2023-10-25 14:30:23 -04:00
Alex Hart
aaf0cf53d8 Remove number suffix of iban text as it is redundant. 2023-10-25 14:30:23 -04:00
Cody Henthorne
9c8f759732 Fix group call not ringing/notifying bug when starting a call. 2023-10-25 14:30:23 -04:00
Nicholas Tinsley
a45c685893 Increase logging during registration. 2023-10-25 14:30:23 -04:00
Jordan Rose
87bdebb21c Remove dependency on presentations being present in AddMemberAction. 2023-10-25 14:30:00 -04:00
Greyson Parrelli
4f754ae309 Centralize media message inserts. 2023-10-23 14:31:39 -04:00
Greyson Parrelli
4b004f70ec Update website build to use PackageInstaller. 2023-10-23 14:30:37 -04:00
Greyson Parrelli
d468d4c21b Remove sms/mms receive code.
Simplifying incoming message insert. Removing this dead path as part of
it.
2023-10-23 13:29:07 -04:00
Alex Hart
a4df433d80 Add proper endpoint for setting iDEAL default payment method. 2023-10-23 14:13:13 -03:00
Alex Hart
10eec025d2 Implement pending one-time donation error handling. 2023-10-23 13:50:54 -03:00
Alex Hart
d497ed4195 Handle launch to external bank application. 2023-10-23 09:26:31 -03:00
Alex Hart
e63137d293 Add bank icons and ideal logo. 2023-10-20 15:28:10 -04:00
Cody Henthorne
c744743913 Bump version to 6.37.2 2023-10-20 14:44:28 -04:00
Cody Henthorne
42493c8eb6 Updated baseline profile. 2023-10-20 14:34:45 -04:00
Cody Henthorne
391839028f Update translations and other static files. 2023-10-20 14:29:31 -04:00
Cody Henthorne
d9ecfeadc0 Add prompt to re-enable full screen intent notifications. 2023-10-20 14:22:08 -04:00
Greyson Parrelli
d866646f66 Update enum for phone number sharing mode. 2023-10-20 14:22:08 -04:00
Alex Hart
6295041341 Fix paypal one-time donation handling. 2023-10-20 14:22:08 -04:00
Alex Hart
8c7556427a Fix temporary screenshot security functionality. 2023-10-20 14:22:08 -04:00
Alex Hart
82c91db78c Fix SaveStateHandler viewModel delegate. 2023-10-20 11:26:37 -03:00
Alex Hart
2d969f4fff Reset scroll position to 0 on contact selection list commit. 2023-10-20 10:40:39 -03:00
Alex Hart
e84d46dae7 Add check for call link prefix before parsing. 2023-10-20 10:33:01 -03:00
Alex Hart
b6828b54ca Fix group calling update messages. 2023-10-20 10:17:31 -03:00
Cody Henthorne
f9bd1bac36 Revert "Upgrade eventbus to 3.3.1"
This reverts commit 89199b81ab.
2023-10-19 13:11:13 -04:00
Cody Henthorne
22e2bfacae Bump version to 6.37.1 2023-10-19 10:53:15 -04:00
Cody Henthorne
c446d4bb54 Fix crash in pni typing migration. 2023-10-19 10:39:08 -04:00
Cody Henthorne
23c7e5dc3f Bump version to 6.37.0 2023-10-18 17:08:26 -04:00
Cody Henthorne
661f1e624c Updated baseline profile. 2023-10-18 16:29:37 -04:00
Cody Henthorne
81ff5ef899 Update translations and other static files. 2023-10-18 16:23:23 -04:00
Cody Henthorne
e79364cb03 Fix pni decryption error. 2023-10-18 16:14:58 -04:00
Nicholas
d750e2fe7a Do not update media preview fragment state upon window transition. 2023-10-18 16:14:58 -04:00
Alex Hart
5e1025453a Implement beginnings of support for iDEAL payments. 2023-10-18 16:14:58 -04:00
Alex Hart
280da481ee Implement Stripe Failure Code support. 2023-10-18 16:14:58 -04:00
Jim Gustafson
9da5f47623 Update to RingRTC v2.34.0 2023-10-18 16:14:58 -04:00
Cody Henthorne
45f1f419e1 Add internal setting to log prekey ids. 2023-10-18 16:14:58 -04:00
Alex Hart
92f2ac67d5 Add proguard keep entry for org.signal.donations.json.** 2023-10-18 16:14:58 -04:00
Jordan Rose
d28a62d70b Improve signalwebp JNI. 2023-10-18 16:14:58 -04:00
Alex Hart
f9336f2a28 Rename DonationErrorSource value to MONTHLY. 2023-10-17 11:15:56 -04:00
Alex Hart
940e67b1ca Rename DonationErrorSource value to ONE_TIME and document. 2023-10-17 11:15:56 -04:00
Alex Hart
073e138ab2 Trim IBAN input before validating value. 2023-10-17 11:15:56 -04:00
Alex Hart
5aec4b4571 Remove alpha from pending badge states. 2023-10-17 11:15:56 -04:00
Alex Hart
f9cd3decb1 Fix several issues with proper pending state routing. 2023-10-17 11:15:56 -04:00
Alex Hart
627c47b155 Implement donations one-time pending state. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
57135ea2c6 Add more logging to forwarding bottom sheet. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
609e9fcdb0 Remove all unused KBS/SVR1 code. 2023-10-17 11:15:56 -04:00
Cody Henthorne
5b0e71b680 Fix dialog dismiss crash in debuglog prompt. 2023-10-17 11:15:56 -04:00
Cody Henthorne
9c2d478797 Skip sends to users with prekey failures. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
c55fa13038 Add some new PNP merge tests. 2023-10-17 11:15:56 -04:00
Alex Hart
27b9565d2f Update TextInputLayout Style and Naming. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
4fe6d79fff Unify our Base64 utilities. 2023-10-17 11:15:56 -04:00
Cody Henthorne
e636e38ba1 Fix NPE in contact attachment processing. 2023-10-17 11:15:56 -04:00
Alex Hart
ebc6665224 Implement small screen support for BankTransferMandateFragment. 2023-10-17 11:15:56 -04:00
Alex Hart
7001cedbc7 Add lifecycle aware temporary screenshot security component. 2023-10-17 11:15:56 -04:00
Alex Hart
b14209d5cf Add new styling for active subscription pref item. 2023-10-17 11:15:56 -04:00
Alex Hart
5150564fe2 Reduce donation configuration TTL to 1 hour. 2023-10-17 11:15:56 -04:00
Lakshay Bomotra
b7eaa9e353 Fix issue with new group members count. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
c00943591d Remove PNP flag from reading some settings. 2023-10-17 11:15:56 -04:00
Cody Henthorne
1f9320200a Sync keys with linked devices. 2023-10-17 11:15:56 -04:00
Cody Henthorne
6a6b80cce2 Decrease db thrashing when starting expiration timers for messages. 2023-10-17 11:15:56 -04:00
Alex Hart
05296e3d9b Add proper text for pending sheet. 2023-10-17 11:15:56 -04:00
Alex Hart
7e68050e0a Add proper pending bank transfer urls. 2023-10-17 11:15:56 -04:00
Alex Hart
ab928be1b3 Suppress checking for messages on application foreground. 2023-10-17 11:15:56 -04:00
Alex Hart
65d26d753d Disable SEPA Debit for gifts. 2023-10-17 11:15:56 -04:00
Alex Hart
bf37c09ba0 Implement bank transfer completed sheet. 2023-10-17 11:15:56 -04:00
Grzegorz Bobryk
89199b81ab Upgrade eventbus to 3.3.1 2023-10-17 11:15:56 -04:00
Alex Hart
0dd17673f5 Implement bank transfer pending sheet. 2023-10-17 11:15:56 -04:00
Alex Hart
c17d6c2334 Implement gateway ordering. 2023-10-17 11:15:56 -04:00
Cody Henthorne
5285dd1665 Fix NPE in account record proto parsing. 2023-10-17 11:15:56 -04:00
Alex Hart
046ce30e08 Fix SGNL schema link for call links. 2023-10-17 11:15:56 -04:00
Alex Hart
1601fa5608 Update SEPA mandate acceptance parameters. 2023-10-17 11:15:56 -04:00
Alex Hart
5f7099184d Add new credit card and bank transfer glyphs. 2023-10-17 11:15:56 -04:00
Alex Hart
8425bb4f59 Update IBAN character limit in information string. 2023-10-17 11:15:56 -04:00
Bernie Dolan
e44006f531 Update MobileCoin SDK to 5.0.1 2023-10-17 11:15:56 -04:00
Alex Hart
3423e24de6 Add donation pending sheet for SEPA transfers. 2023-10-17 11:15:56 -04:00
Alex Hart
5ac363232f Implement isLongRunning wiring for receipt redemption jobs. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
9cc020a2c7 Move the video lib to the proper directory. 2023-10-17 11:15:56 -04:00
Alex Hart
d2240f07d8 Add privacy and accounts sheets for SEPA. 2023-10-17 11:15:56 -04:00
Greyson Parrelli
4968db750b Move libsignal-service up a directory. 2023-10-17 11:15:55 -04:00
Alex Hart
6134244244 Update radii and margins of one-time-donation selection grid. 2023-10-17 11:15:55 -04:00
Cody Henthorne
4559ca9f2b Bump version to 6.36.5 2023-10-17 11:12:12 -04:00
Cody Henthorne
9a38920cb8 Updated baseline profile. 2023-10-17 11:02:21 -04:00
Cody Henthorne
2b771931e6 Update translations and other static files. 2023-10-17 10:57:35 -04:00
Cody Henthorne
d72e003f8c Fix delete account bug. 2023-10-17 10:33:30 -04:00
1009 changed files with 28893 additions and 16407 deletions

View File

@@ -25,7 +25,7 @@ wire {
}
protoPath {
srcDir "${project.rootDir}/libsignal/service/src/main/protowire"
srcDir "${project.rootDir}/libsignal-service/src/main/protowire"
}
}
@@ -33,8 +33,8 @@ ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1346
def canonicalVersionName = "6.36.4"
def canonicalVersionCode = 1360
def canonicalVersionName = "6.39.3"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -185,7 +185,6 @@ android {
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
buildConfigField "String[]", "SIGNAL_CDSI_IPS", cdsi_ips
@@ -193,12 +192,6 @@ android {
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
buildConfigField "String", "SVR2_MRENCLAVE", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\""
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
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\""
@@ -326,26 +319,27 @@ android {
play {
dimension 'distribution'
isDefault true
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "null"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
}
website {
dimension 'distribution'
ext.websiteUpdateUrl = "https://updates.signal.org/android"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
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\""
}
nightly {
def apkUpdateManifestUrl = "<unset>"
if (project.hasProperty('nightlyApkUpdateManifestUrl')) {
apkUpdateManifestUrl = project.getProperty('nightlyApkUpdateManifestUrl')
}
dimension 'distribution'
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
ext.websiteUpdateUrl = "null"
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
@@ -372,12 +366,6 @@ android {
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 "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
"\"ee1d0d972b7ea903615670de43ab1b6e7a825e811c70a29bb5fe0f819e0975fa\", " +
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
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\""
@@ -416,6 +404,9 @@ android {
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")
@@ -675,6 +666,23 @@ tasks.withType(Test) {
}
}
project.tasks.configureEach { task ->
if (task.name.toLowerCase().contains("nightly") && task.name != 'checkNightlyParams') {
task.dependsOn checkNightlyParams
}
}
tasks.register('checkNightlyParams') {
doFirst {
if (project.gradle.startParameter.taskNames.any { it.toLowerCase().contains("nightly") }) {
if (!project.hasProperty('nightlyApkUpdateManifestUrl')) {
throw new GradleException("Required command-line parameter 'nightlyApkUpdateManifestUrl' not found for nightly build!")
}
}
}
}
def loadKeystoreProperties(filename) {
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
if (keystorePropertiesFile.exists()) {

View File

@@ -4,6 +4,7 @@
-keep class org.whispersystems.** { *; }
-keep class org.signal.libsignal.protocol.** { *; }
-keep class org.thoughtcrime.securesms.** { *; }
-keep class org.signal.donations.json.** { *; }
-keepclassmembers class ** {
public void onEvent*(**);
}

View File

@@ -230,8 +230,6 @@ class ChangeNumberViewModelTest {
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
MockProvider.mockGetRegistrationLockStringFlow()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
@@ -318,8 +316,6 @@ class ChangeNumberViewModelTest {
lateinit var changeNumberRequest: ChangePhoneNumberRequest
lateinit var setPreKeysRequest: PreKeyState
MockProvider.mockGetRegistrationLockStringFlow()
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },

View File

@@ -9,8 +9,9 @@ import org.junit.runner.RunWith
import org.signal.core.util.ThreadUtil
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.profiles.ProfileName
import org.thoughtcrime.securesms.recipients.Recipient
@@ -64,7 +65,8 @@ class ConversationItemPreviewer {
attachment()
}
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
body = body,
sentTimeMillis = System.currentTimeMillis(),
@@ -73,7 +75,7 @@ class ConversationItemPreviewer {
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
ThreadUtil.sleep(1)
}
@@ -83,7 +85,8 @@ class ConversationItemPreviewer {
attachment()
}
val message = IncomingMediaMessage(
val message = IncomingMessage(
type = MessageType.NORMAL,
from = other.id,
body = body,
sentTimeMillis = System.currentTimeMillis(),
@@ -92,7 +95,7 @@ class ConversationItemPreviewer {
attachments = PointerAttachment.forPointers(Optional.of(attachments))
)
val insert = SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
val insert = SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
SignalDatabase.attachments.getAttachmentsForMessage(insert.messageId).forEachIndexed { index, attachment ->
// if (index != 1) {

View File

@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.Optional
@@ -55,9 +55,9 @@ object MmsHelper {
}
fun insert(
message: IncomingMediaMessage,
message: IncomingMessage,
threadId: Long
): Optional<MessageTable.InsertResult> {
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, threadId)
return SignalDatabase.messages.insertMessageInbox(message, threadId)
}
}

View File

@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.ParentStoryId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@@ -73,7 +73,8 @@ class MmsTableTest_stories {
)
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = 2,
serverTimeMillis = 2,
@@ -95,7 +96,8 @@ class MmsTableTest_stories {
// GIVEN
val sender = recipients[0]
val messageId = MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = 2,
serverTimeMillis = 2,
@@ -122,7 +124,8 @@ class MmsTableTest_stories {
// GIVEN
val messageIds = recipients.take(5).map {
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = it,
sentTimeMillis = 2,
serverTimeMillis = 2,
@@ -154,7 +157,8 @@ class MmsTableTest_stories {
val unviewedIds: List<Long> = (0 until 5).map {
Thread.sleep(5)
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[it],
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
@@ -168,7 +172,8 @@ class MmsTableTest_stories {
val viewedIds: List<Long> = (0 until 5).map {
Thread.sleep(5)
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[it],
sentTimeMillis = System.currentTimeMillis(),
serverTimeMillis = 2,
@@ -213,7 +218,8 @@ class MmsTableTest_stories {
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
// GIVEN
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = recipients[0],
sentTimeMillis = 200,
serverTimeMillis = 2,
@@ -321,7 +327,8 @@ class MmsTableTest_stories {
)
MmsHelper.insert(
IncomingMediaMessage(
IncomingMessage(
type = MessageType.NORMAL,
from = myStory.id,
sentTimeMillis = 201,
serverTimeMillis = 201,

View File

@@ -14,8 +14,10 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.SqlUtil
import org.signal.core.util.exists
import org.signal.core.util.orNull
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.select
@@ -34,12 +36,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.thoughtcrime.securesms.util.Util
@@ -142,6 +142,30 @@ class RecipientTableTest_getAndPossiblyMerge {
process(null, null, null)
}
test("pni matches, pni+aci provided, no pni session") {
given(E164_A, PNI_A, null)
process(null, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectNoSessionSwitchoverEvent()
}
test("pni matches, pni+aci provided, pni session") {
given(E164_A, PNI_A, null, pniSession = true)
process(null, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
expectSessionSwitchoverEvent(E164_A)
}
test("pni matches, pni+aci provided, pni session, pni-verified") {
given(E164_A, PNI_A, null, pniSession = true)
process(null, PNI_A, ACI_A, pniVerified = true)
expect(E164_A, PNI_A, ACI_A)
expectNoSessionSwitchoverEvent()
}
test("no match, all fields") {
process(E164_A, PNI_A, ACI_A)
expect(E164_A, PNI_A, ACI_A)
@@ -801,9 +825,9 @@ class RecipientTableTest_getAndPossiblyMerge {
val smsId2: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
val smsId3: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
val mmsId1: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val mmsId1: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
val mmsId2: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
val mmsId3: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
val threadIdAci: Long = SignalDatabase.threads.getThreadIdFor(recipientIdAci)!!
val threadIdE164: Long = SignalDatabase.threads.getThreadIdFor(recipientIdE164)!!
@@ -923,12 +947,30 @@ class RecipientTableTest_getAndPossiblyMerge {
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
}
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = time,
serverTimeMillis = time,
receivedTimeMillis = time,
body = body,
groupId = groupId.orNull(),
isUnidentified = true
)
}
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty(), false, false)
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMessage {
return IncomingMessage(
type = MessageType.NORMAL,
from = sender,
groupId = groupId.orNull(),
body = body,
sentTimeMillis = time,
receivedTimeMillis = time,
serverTimeMillis = time,
isUnidentified = true
)
}
private fun identityKey(value: Byte): IdentityKey {

View File

@@ -18,12 +18,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.groupChange
import org.thoughtcrime.securesms.database.model.databaseprotos.groupContext
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import java.util.Optional
import java.util.UUID
@Suppress("ClassName", "TestFunctionName")
@@ -272,13 +270,28 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
assertThat("latest message should be deleted", sms.getMessageRecordOrNull(latestMessage.messageId), nullValue())
}
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingTextMessage {
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingMessage {
wallClock++
return IncomingTextMessage(sender, 1, wallClock, wallClock, wallClock, body, Optional.of(groupId), 0, true, null)
return IncomingMessage(
type = MessageType.NORMAL,
from = sender,
sentTimeMillis = wallClock,
serverTimeMillis = wallClock,
receivedTimeMillis = wallClock,
body = body,
groupId = groupId,
isUnidentified = true
)
}
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingGroupUpdateMessage {
return IncomingGroupUpdateMessage(smsMessage(sender, null), groupContext)
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingMessage {
wallClock++
return IncomingMessage.groupUpdate(
from = sender,
timestamp = wallClock,
groupId = groupId,
groupContext = groupContext
)
}
companion object {

View File

@@ -13,9 +13,9 @@ import okio.ByteString
import org.mockito.kotlin.any
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.KbsEnclave
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
@@ -23,32 +23,25 @@ import org.thoughtcrime.securesms.testing.Get
import org.thoughtcrime.securesms.testing.Verb
import org.thoughtcrime.securesms.testing.runSync
import org.thoughtcrime.securesms.testing.success
import org.thoughtcrime.securesms.util.Base64
import org.whispersystems.signalservice.api.KeyBackupService
import org.whispersystems.signalservice.api.SignalServiceAccountManager
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
import java.security.KeyStore
import java.util.Optional
/**
* Dependency provider used for instrumentation tests (aka androidTests).
*
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess] and
* [KeyBackupService].
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess].
*/
class InstrumentationApplicationDependencyProvider(application: Application, default: ApplicationDependencyProvider) : ApplicationDependencies.Provider by default {
private val serviceTrustStore: TrustStore
private val uncensoredConfiguration: SignalServiceConfiguration
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
private val keyBackupService: KeyBackupService
private val recipientCache: LiveRecipientCache
init {
@@ -80,7 +73,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
),
signalKeyBackupServiceUrls = arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalStorageUrls = arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalCdsiUrls = arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
signalSvr2Urls = arrayOf(SignalSvr2Url(baseUrl, serviceTrustStore, "localhost", ConnectionSpec.CLEARTEXT)),
@@ -97,8 +89,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
on { uncensoredConfiguration } doReturn uncensoredConfiguration
}
keyBackupService = mock()
recipientCache = LiveRecipientCache(application) { r -> r.run() }
}
@@ -106,10 +96,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
return serviceNetworkAccessMock
}
override fun provideKeyBackupService(signalServiceAccountManager: SignalServiceAccountManager, keyStore: KeyStore, enclave: KbsEnclave): KeyBackupService {
return keyBackupService
}
override fun provideRecipientCache(): LiveRecipientCache {
return recipientCache
}

View File

@@ -8,6 +8,7 @@ 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
@@ -20,7 +21,6 @@ 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
import org.whispersystems.util.Base64UrlSafe
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
@@ -58,13 +58,13 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(serverUsername))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
@@ -94,7 +94,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
@@ -122,13 +122,13 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(username))
}
)
},
Put("/v1/accounts/username_hash/reserve") { r ->
didReserve = true
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
},
Put("/v1/accounts/username_hash/confirm") { r ->
didConfirm = true
@@ -156,7 +156,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
Get("/v1/accounts/whoami") { r ->
MockResponse().success(
WhoAmIResponse().apply {
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash("${username}23"))
}
)
},

View File

@@ -6,12 +6,12 @@ import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.Base64
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
import org.whispersystems.signalservice.api.push.ServiceId.ACI
@@ -113,7 +113,7 @@ class ContactRecordProcessorTest {
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
SignalDatabase.rawDatabase
.update(RecipientTable.TABLE_NAME)
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeBytes(storageId.raw))
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId.raw))
.where("${RecipientTable.ID} = ?", recipientId)
.run()
}

View File

@@ -1,6 +1,7 @@
package org.thoughtcrime.securesms.testing
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
import org.signal.libsignal.internal.Native
import org.signal.libsignal.internal.NativeHandleGuard
import org.signal.libsignal.metadata.certificate.CertificateValidator
@@ -20,7 +21,6 @@ import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Envelope
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
import org.whispersystems.util.Base64
import java.util.Optional
import java.util.UUID

View File

@@ -1,22 +1,12 @@
package org.thoughtcrime.securesms.testing
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.signal.core.util.Hex
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.state.PreKeyRecord
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.protocol.util.Medium
import org.signal.libsignal.svr2.PinHash
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.test.BuildConfig
import org.whispersystems.signalservice.api.KeyBackupService
import org.whispersystems.signalservice.api.SvrPinData
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
@@ -78,18 +68,6 @@ object MockProvider {
}
}
fun mockGetRegistrationLockStringFlow() {
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
override fun restorePin(hashedPin: PinHash?): SvrPinData = SvrPinData(MasterKey.createNew(SecureRandom()), null)
}
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
kbsService.stub {
on { newRegistrationSession(anyOrNull(), anyOrNull()) } doReturn session
}
}
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account().aciIdentityKey, deviceId: Int): PreKeyResponse {
val signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), identity.privateKey)
val oneTimePreKey = PreKeyRecord(SecureRandom().nextInt(Medium.MAX_VALUE), Curve.generateKeyPair())

View File

@@ -38,10 +38,8 @@ object MessageTableTestUtils {
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}

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

@@ -1189,6 +1189,10 @@
android:name=".service.GenericForegroundService"
android:exported="false"/>
<service
android:name=".service.AttachmentProgressService"
android:exported="false"/>
<service
android:name=".gcm.FcmFetchBackgroundService"
android:exported="false"/>
@@ -1203,18 +1207,6 @@
</intent-filter>
</service>
<receiver android:name=".service.SmsListener"
android:permission="android.permission.BROADCAST_SMS"
android:enabled="true"
android:exported="true">
<intent-filter android:priority="1001">
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
</intent-filter>
<intent-filter>
<action android:name="android.provider.Telephony.SMS_DELIVER"/>
</intent-filter>
</receiver>
<receiver android:name=".service.SmsDeliveryListener"
android:exported="true">
<intent-filter>
@@ -1222,20 +1214,6 @@
</intent-filter>
</receiver>
<receiver android:name=".service.MmsListener"
android:enabled="true"
android:exported="true"
android:permission="android.permission.BROADCAST_WAP_PUSH">
<intent-filter android:priority="1001">
<action android:name="android.provider.Telephony.WAP_PUSH_RECEIVED"/>
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER"/>
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
<receiver android:name=".notifications.MarkReadReceiver"
android:enabled="true"
android:exported="false">

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@ import org.conscrypt.Conscrypt;
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;
@@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob;
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
@@ -83,7 +85,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.service.LocalBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -108,6 +110,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;
/**
@@ -150,6 +153,7 @@ 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)
@@ -224,7 +228,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
ApplicationDependencies.getDeadlockDetector().start();
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
ExternalLaunchDonationJob.enqueueIfNecessary();
FcmFetchManager.onForeground(this);
startAnrDetector();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
@@ -258,6 +264,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getShakeToReport().disable();
ApplicationDependencies.getDeadlockDetector().stop();
MemoryTracker.stop();
AnrDetector.stop();
}
public void checkBuildExpiration() {
@@ -267,6 +274,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);
@@ -397,8 +415,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
RotateSenderCertificateListener.schedule(this);
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
if (BuildConfig.PLAY_STORE_DISABLED) {
UpdateApkRefreshListener.schedule(this);
if (BuildConfig.MANAGES_APP_UPDATES) {
ApkUpdateRefreshListener.schedule(this);
}
}

View File

@@ -145,6 +145,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
private Set<RecipientId> currentSelection;
private boolean isMulti;
private boolean canSelectSelf;
private boolean resetPositionOnCommit = false;
private ListClickListener listClickListener = new ListClickListener();
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
@@ -423,6 +425,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
onRefreshListener = null;
}
public int getSelectedMembersSize() {
return contactSearchMediator.getSelectedMembersSize();
}
private @NonNull Bundle safeArguments() {
return getArguments() != null ? getArguments() : new Bundle();
}
@@ -523,12 +529,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
return;
}
this.cursorFilter = filter;
this.resetPositionOnCommit = true;
this.cursorFilter = filter;
contactSearchMediator.onFilterChanged(filter);
}
public void resetQueryFilter() {
setQueryFilter(null);
this.resetPositionOnCommit = true;
swipeRefresh.setRefreshing(false);
}
@@ -547,6 +558,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
}
private void onLoadFinished(int count) {
if (resetPositionOnCommit) {
resetPositionOnCommit = false;
recyclerView.scrollToPosition(0);
}
swipeRefresh.setVisibility(View.VISIBLE);
showContactsLayout.setVisibility(View.GONE);

View File

@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.util.Base64;
import org.signal.core.util.Base64;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Provided to the DownloadManager as a callback receiver for when it has finished downloading the APK we're trying to install.
*
* Registered in the manifest to list to [DownloadManager.ACTION_DOWNLOAD_COMPLETE].
*/
class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() {
companion object {
private val TAG = Log.tag(ApkUpdateDownloadManagerReceiver::class.java)
}
override fun onReceive(context: Context, intent: Intent) {
Log.i(TAG, "onReceive()")
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE != intent.action) {
Log.i(TAG, "Unexpected action: " + intent.action)
return
}
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2)
if (downloadId != SignalStore.apkUpdate().downloadId) {
Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.")
return
}
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = false)
}
}

View File

@@ -0,0 +1,147 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import org.signal.core.util.PendingIntentFlags
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.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FileUtils
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
import java.security.MessageDigest
object ApkUpdateInstaller {
private val TAG = Log.tag(ApkUpdateInstaller::class.java)
/**
* Installs the downloaded APK silently if possible. If not, prompts the user with a notification to install.
* May show errors instead under certain conditions.
*
* A common pattern you may see is that this is called with [userInitiated] = false (or some other state
* that prevents us from auto-updating, like the app being in the foreground), causing this function
* to show an install prompt notification. The user clicks that notification, calling this with
* [userInitiated] = true, and then everything installs.
*/
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
if (downloadId != SignalStore.apkUpdate().downloadId) {
Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.")
return
}
val digest = SignalStore.apkUpdate().digest
if (digest == null) {
Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate().clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!isMatchingDigest(context, downloadId, digest)) {
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
SignalStore.apkUpdate().clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
return
}
if (!userInitiated && !shouldAutoUpdate()) {
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${ApplicationDependencies.getAppForegroundObserver().isForegrounded}, AutoUpdate=${SignalStore.apkUpdate().autoUpdate})")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
try {
installApk(context, downloadId, userInitiated)
} catch (e: IOException) {
Log.w(TAG, "Hit IOException when trying to install APK!", e)
SignalStore.apkUpdate().clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
} catch (e: SecurityException) {
Log.w(TAG, "Hit SecurityException when trying to install APK!", e)
SignalStore.apkUpdate().clearDownloadAttributes()
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
}
}
@Throws(IOException::class, SecurityException::class)
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
if (apkInputStream == null) {
Log.w(TAG, "Could not open download APK input stream!")
return
}
Log.d(TAG, "Beginning APK install...")
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
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.
if (Build.VERSION.SDK_INT >= 31) {
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
Log.d(TAG, "Creating install session...")
val sessionId: Int = packageInstaller.createSession(sessionParams)
val session: PackageInstaller.Session = packageInstaller.openSession(sessionId)
Log.d(TAG, "Writing APK data...")
session.use { activeSession ->
val sessionOutputStream = activeSession.openWrite(context.packageName, 0, -1)
StreamUtil.copy(apkInputStream, sessionOutputStream)
}
val installerPendingIntent = PendingIntent.getBroadcast(
context,
sessionId,
Intent(context, ApkUpdatePackageInstallerReceiver::class.java).apply {
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_USER_INITIATED, userInitiated)
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_DOWNLOAD_ID, downloadId)
},
PendingIntentFlags.mutable() or PendingIntentFlags.updateCurrent()
)
Log.d(TAG, "Committing session...")
session.commit(installerPendingIntent.intentSender)
}
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
return try {
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
val digest = FileUtils.getFileDigest(stream)
MessageDigest.isEqual(digest, expectedDigest)
}
} catch (e: IOException) {
Log.w(TAG, e)
false
}
}
private fun shouldAutoUpdate(): Boolean {
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
}
}

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import org.signal.core.util.logging.Log
/**
* Receiver that is triggered based on various notification actions that can be taken on update-related notifications.
*/
class ApkUpdateNotificationReceiver : BroadcastReceiver() {
companion object {
private val TAG = Log.tag(ApkUpdateNotificationReceiver::class.java)
const val ACTION_INITIATE_INSTALL = "signal.apk_update_notification.initiate_install"
const val EXTRA_DOWNLOAD_ID = "signal.download_id"
}
override fun onReceive(context: Context, intent: Intent?) {
if (intent == null) {
Log.w(TAG, "Null intent")
return
}
val downloadId: Long = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2)
when (val action: String? = intent.action) {
ACTION_INITIATE_INSTALL -> handleInstall(context, downloadId)
else -> Log.w(TAG, "Unrecognized notification action: $action")
}
}
private fun handleInstall(context: Context, downloadId: Long) {
Log.i(TAG, "Got action to install.")
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = true)
}
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import org.signal.core.util.PendingIntentFlags
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.util.ServiceUtil
object ApkUpdateNotifications {
val TAG = Log.tag(ApkUpdateNotifications::class.java)
/**
* Shows a notification to prompt the user to install the app update. Only shown when silently auto-updating is not possible or are disabled by the user.
* Note: This is an 'ongoing' notification (i.e. not-user dismissable) and never dismissed programatically. This is because the act of installing the APK
* will dismiss it for us.
*/
@SuppressLint("LaunchActivityFromNotification")
fun showInstallPrompt(context: Context, downloadId: Long) {
val pendingIntent = PendingIntent.getBroadcast(
context,
1,
Intent(context, ApkUpdateNotificationReceiver::class.java).apply {
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
},
PendingIntentFlags.immutable()
)
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
.setOngoing(true)
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_prompt_install_title))
.setContentText(context.getString(R.string.ApkUpdateNotifications_prompt_install_body))
.setSmallIcon(R.drawable.ic_notification)
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
.setContentIntent(pendingIntent)
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
}
fun showInstallFailed(context: Context, reason: FailureReason) {
val pendingIntent = PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java),
PendingIntentFlags.immutable()
)
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_failed_general_title))
.setContentText(context.getString(R.string.ApkUpdateNotifications_failed_general_body))
.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,
BLOCKED,
INCOMPATIBLE,
INVALID,
CONFLICT,
STORAGE,
TIMEOUT
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
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
/**
* This is the receiver that is triggered by the [PackageInstaller] to notify of various events. Package installation is initiated
* in [ApkUpdateInstaller].
*/
class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
companion object {
private val TAG = Log.tag(ApkUpdatePackageInstallerReceiver::class.java)
const val EXTRA_USER_INITIATED = "signal.user_initiated"
const val EXTRA_DOWNLOAD_ID = "signal.download_id"
}
override fun onReceive(context: Context, intent: Intent?) {
val statusCode: Int = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: -1
val statusMessage: String? = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val userInitiated = intent?.getBooleanExtra(EXTRA_USER_INITIATED, false) ?: false
Log.w(TAG, "[onReceive] Status: $statusCode, Message: $statusMessage")
when (statusCode) {
PackageInstaller.STATUS_SUCCESS -> {
Log.i(TAG, "Update installed successfully!")
ApkUpdateNotifications.showAutoUpdateSuccess(context)
}
PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!)
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)
PackageInstaller.STATUS_FAILURE_INVALID -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INVALID)
PackageInstaller.STATUS_FAILURE_CONFLICT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.CONFLICT)
PackageInstaller.STATUS_FAILURE_STORAGE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.STORAGE)
PackageInstaller.STATUS_FAILURE_TIMEOUT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.TIMEOUT)
PackageInstaller.STATUS_FAILURE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.UNKNOWN)
else -> Log.w(TAG, "Unknown status! $statusCode")
}
}
private fun handlePendingUserAction(context: Context, userInitiated: Boolean, intent: Intent) {
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2)
if (!userInitiated) {
Log.w(TAG, "Not user-initiated, but needs user action! Showing prompt notification.")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
val promptIntent: Intent? = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT, Intent::class.java)
if (promptIntent == null) {
Log.w(TAG, "Missing prompt intent! Showing prompt notification instead.")
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
return
}
promptIntent.apply {
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(promptIntent)
}
}

View File

@@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.service;
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.apkupdate;
import android.content.Context;
@@ -6,16 +11,18 @@ import android.content.Context;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
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;
public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener {
private static final String TAG = Log.tag(UpdateApkRefreshListener.class);
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) {
@@ -26,9 +33,9 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
protected long onAlarm(Context context, long scheduledTime) {
Log.i(TAG, "onAlarm...");
if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) {
if (scheduledTime != 0 && BuildConfig.MANAGES_APP_UPDATES) {
Log.i(TAG, "Queueing APK update job...");
ApplicationDependencies.getJobManager().add(new UpdateApkJob());
ApplicationDependencies.getJobManager().add(new ApkUpdateJob());
}
long newTime = System.currentTimeMillis() + INTERVAL;
@@ -38,7 +45,7 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
}
public static void schedule(Context context) {
new UpdateApkRefreshListener().onReceive(context, getScheduleIntent());
new ApkUpdateRefreshListener().onReceive(context, getScheduleIntent());
}
}

View File

@@ -9,7 +9,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.blurhash.BlurHash;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.Base64;
import org.signal.core.util.Base64;
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
@@ -108,7 +108,7 @@ public class PointerAttachment extends Attachment {
String encodedKey = null;
if (pointer.get().asPointer().getKey() != null) {
encodedKey = Base64.encodeBytes(pointer.get().asPointer().getKey());
encodedKey = Base64.encodeWithPadding(pointer.get().asPointer().getKey());
}
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
@@ -144,7 +144,7 @@ public class PointerAttachment extends Attachment {
pointer.getFileName(),
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
null,
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
@@ -175,7 +175,7 @@ public class PointerAttachment extends Attachment {
quotedAttachment.fileName,
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
null,
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,

View File

@@ -8,7 +8,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
import org.thoughtcrime.securesms.util.ParcelUtil;
import org.whispersystems.util.Base64;
import org.signal.core.util.Base64;
import java.io.IOException;
import java.util.Objects;
@@ -27,7 +27,7 @@ public final class AudioHash implements Parcelable {
}
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
this(Base64.encodeBytes(audioWaveForm.encode()), audioWaveForm);
this(Base64.encodeWithPadding(audioWaveForm.encode()), audioWaveForm);
}
protected AudioHash(Parcel in) {

View File

@@ -8,6 +8,7 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSize
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.glide.GiftBadgeModel
import org.thoughtcrime.securesms.mms.GlideApp
@@ -31,6 +32,10 @@ class BadgeImageView @JvmOverloads constructor(
isClickable = false
}
constructor(context: Context, badgeImageSize: BadgeImageSize) : this(context) {
badgeSize = badgeImageSize.sizeCode
}
override fun setOnClickListener(l: OnClickListener?) {
val wasClickable = isClickable
super.setOnClickListener(l)

View File

@@ -1,13 +1,13 @@
package org.thoughtcrime.securesms.badges.gifts
import android.content.Context
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import java.lang.Integer.min
import java.util.concurrent.TimeUnit
@@ -32,7 +32,7 @@ object Gifts {
): OutgoingMessage {
return OutgoingMessage(
threadRecipient = recipient,
body = Base64.encodeBytes(giftBadge.encode()),
body = Base64.encodeWithPadding(giftBadge.encode()),
isSecure = true,
sentTimeMillis = sentTimestamp,
expiresIn = expiresIn,

View File

@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
@@ -263,8 +264,12 @@ class GiftFlowConfirmationFragment :
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
error("Unsupported operation")
}
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
error("Unsupported operation")
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
@@ -280,7 +285,10 @@ class GiftFlowConfirmationFragment :
}
override fun onProcessorActionProcessed() = Unit
override fun onUserCancelledPaymentFlow() {
findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false)
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
}

View File

@@ -6,6 +6,7 @@ import android.os.Parcelable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.compose.runtime.Stable
import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
@@ -25,6 +26,7 @@ typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
/**
* A Badge that can be collected and displayed by a user.
*/
@Stable
@Parcelize
data class Badge(
val id: String,

View File

@@ -4,6 +4,7 @@ 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
@@ -38,6 +39,7 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
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
@@ -69,6 +71,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
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 {

View File

@@ -58,7 +58,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
space(DimensionUnit.DP.toPixels(32f).toInt())
tonalButton(
tonalWrappedButton(
text = DSLSettingsText.from(
R.string.BecomeASustainerMegaphone__become_a_sustainer
),

View File

@@ -23,7 +23,7 @@ import java.net.URLDecoder
object CallLinks {
private const val ROOT_KEY = "key"
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/#key="
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/call/#key="
private val TAG = Log.tag(CallLinks::class.java)

View File

@@ -88,11 +88,11 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
disposables += viewModel.submitLogs().subscribe({ result ->
submitLogs(result, purpose)
progressDialog.dismiss()
dismiss()
dismissAllowingStateLoss()
}, { _ ->
Toast.makeText(requireContext(), getString(R.string.HelpFragment__could_not_upload_logs), Toast.LENGTH_LONG).show()
progressDialog.dismiss()
dismiss()
dismissAllowingStateLoss()
})
}
@@ -101,7 +101,7 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
SignalStore.uiHints().markDeclinedShareNotificationLogs()
}
dismiss()
dismissAllowingStateLoss()
}
}

View File

@@ -22,7 +22,7 @@ public class RatingManager {
private static final String TAG = Log.tag(RatingManager.class);
public static void showRatingDialogIfNecessary(Context context) {
if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.PLAY_STORE_DISABLED) return;
if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.MANAGES_APP_UPDATES) return;
long daysSinceInstall = VersionTracker.getDaysSinceFirstInstalled(context);
long laterTimestamp = TextSecurePreferences.getRatingLaterTimestamp(context);

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.util.TextSecurePreferences
/**
* Applies temporary screenshot security for the given component lifecycle.
*/
object TemporaryScreenshotSecurity {
@JvmStatic
fun bindToViewLifecycleOwner(fragment: Fragment) {
val observer = LifecycleObserver { fragment.requireActivity() }
fragment.viewLifecycleOwner.lifecycle.addObserver(observer)
}
@JvmStatic
fun bind(activity: ComponentActivity) {
val observer = LifecycleObserver { activity }
activity.lifecycle.addObserver(observer)
}
private class LifecycleObserver(
private val activityProvider: () -> ComponentActivity
) : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
override fun onPause(owner: LifecycleOwner) {
val activity = activityProvider()
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
}

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

@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.PreferenceModel
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.SignalStore
@@ -50,6 +51,8 @@ class AppSettingsFragment : DSLSettingsFragment(
private lateinit var reminderView: Stub<ReminderView>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
super.onViewCreated(view, savedInstanceState)
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)

View File

@@ -307,8 +307,8 @@ class ChangeNumberRepository(
return Single.fromCallable {
for (certificateType in certificateTypes) {
val certificate: ByteArray? = when (certificateType) {
CertificateType.UUID_AND_E164 -> accountManager.getSenderCertificate()
CertificateType.UUID_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
CertificateType.ACI_AND_E164 -> accountManager.getSenderCertificate()
CertificateType.ACI_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
else -> throw AssertionError()
}

View File

@@ -0,0 +1,199 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Rows
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueCodeSelector
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Allows configuration of a PendingOneTimeDonation object to display different
* states in the donation settings screen.
*/
class InternalPendingOneTimeDonationConfigurationFragment : ComposeFragment() {
private val viewModel: InternalPendingOneTimeDonationConfigurationViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state
Content(
state,
onNavigationClick = {
findNavController().popBackStack()
},
onAddError = {
viewModel.state.value = viewModel.state.value.copy(error = DonationErrorValue())
},
onClearError = {
viewModel.state.value = viewModel.state.value.copy(error = null)
},
onPaymentMethodTypeSelected = {
viewModel.state.value = viewModel.state.value.copy(paymentMethodType = it, error = null)
},
onErrorTypeSelected = {
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(type = it))
},
onErrorCodeChanged = {
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(code = it))
},
onSave = {
SignalStore.donationsValues().setPendingOneTimeDonation(viewModel.state.value)
findNavController().popBackStack()
}
)
}
}
@Preview
@Composable
private fun ContentPreview() {
SignalTheme {
Surface {
Content(
state = PendingOneTimeDonation.Builder().error(DonationErrorValue()).build(),
onNavigationClick = {},
onClearError = {},
onAddError = {},
onPaymentMethodTypeSelected = {},
onErrorTypeSelected = {},
onErrorCodeChanged = {},
onSave = {}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Content(
state: PendingOneTimeDonation,
onNavigationClick: () -> Unit,
onAddError: () -> Unit,
onClearError: () -> Unit,
onPaymentMethodTypeSelected: (PendingOneTimeDonation.PaymentMethodType) -> Unit,
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit,
onErrorCodeChanged: (String) -> Unit,
onSave: () -> Unit
) {
Scaffolds.Settings(
title = "One-time donation state",
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
navigationContentDescription = null,
onNavigationClick = onNavigationClick
) {
LazyColumn(
horizontalAlignment = CenterHorizontally,
modifier = Modifier.padding(it)
) {
item {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
value = state.paymentMethodType.name,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
PendingOneTimeDonation.PaymentMethodType.values().forEach { item ->
DropdownMenuItem(
text = { Text(text = item.name) },
onClick = {
onPaymentMethodTypeSelected(item)
expanded = false
}
)
}
}
}
}
item {
Rows.ToggleRow(
checked = state.error != null,
text = "Enable error",
onCheckChanged = {
if (it) {
onAddError()
} else {
onClearError()
}
}
)
}
if (state.error != null) {
item {
DonationErrorValueTypeSelector(
selectedPaymentMethodType = state.paymentMethodType,
selectedErrorType = state.error.type,
onErrorTypeSelected = onErrorTypeSelected
)
}
item {
DonationErrorValueCodeSelector(
selectedPaymentMethodType = state.paymentMethodType,
selectedErrorType = state.error.type,
selectedErrorCode = state.error.code,
onErrorCodeSelected = onErrorCodeChanged
)
}
}
item {
Buttons.LargeTonal(
enabled = state.badge != null,
onClick = onSave
) {
Text(text = "Save")
}
}
}
}
}

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal
import androidx.compose.runtime.MutableState
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.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.money.FiatMoney
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import java.math.BigDecimal
import java.util.Currency
import java.util.Locale
/**
* Fetches a badge for our pending donation, which requires downloading the donation config.
*/
class InternalPendingOneTimeDonationConfigurationViewModel : ViewModel() {
val state: MutableState<PendingOneTimeDonation> = mutableStateOf(
PendingOneTimeDonation(
timestamp = System.currentTimeMillis(),
amount = FiatMoney(BigDecimal.valueOf(20), Currency.getInstance("EUR")).toFiatValue()
)
)
val disposable: Disposable = Single
.fromCallable {
ApplicationDependencies.getDonationsService()
.getDonationsConfiguration(Locale.getDefault())
}
.flatMap { it.flattenResult() }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { config ->
val badge = Badges.fromServiceBadge(config.levels.values.first().badge)
state.value = state.value.copy(badge = Badges.toDatabaseBadge(badge))
}
override fun onCleared() {
super.onCleared()
}
}

View File

@@ -14,6 +14,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.AppUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireLong
import org.signal.core.util.requireString
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
@@ -21,10 +25,13 @@ 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
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
@@ -53,6 +60,10 @@ import kotlin.time.Duration.Companion.seconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
companion object {
private val TAG = Log.tag(InternalSettingsFragment::class.java)
}
private lateinit var viewModel: InternalSettingsViewModel
private var scrollToPosition: Int = 0
@@ -161,6 +172,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 = {
@@ -168,6 +190,47 @@ 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 = {
logPreKeyIds()
}
)
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"))
@@ -459,6 +522,29 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
)
}
if (state.hasPendingOneTimeDonation) {
clickPref(
title = DSLSettingsText.from("Clear pending one-time donation."),
onClick = {
SignalStore.donationsValues().setPendingOneTimeDonation(null)
}
)
} else {
clickPref(
title = DSLSettingsText.from("Set pending one-time donation."),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToOneTimeDonationConfigurationFragment())
}
)
}
clickPref(
title = DSLSettingsText.from("Enqueue terminal donation"),
onClick = {
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToTerminalDonationConfigurationFragment())
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Release channel"))
@@ -725,7 +811,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
private fun enqueueSubscriptionRedemption() {
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
-1L,
TerminalDonationQueue.TerminalDonation(
level = 1000
)
).enqueue()
}
private fun enqueueSubscriptionKeepAlive() {
@@ -773,4 +864,19 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show()
}
}
private fun logPreKeyIds() {
SimpleTask.run({
val oneTimePreKeys = SignalDatabase.rawDatabase
.query("SELECT * FROM ${OneTimePreKeyTable.TABLE_NAME}")
.readToList { c ->
c.requireString(OneTimePreKeyTable.ACCOUNT_ID) to c.requireLong(OneTimePreKeyTable.KEY_ID)
}
.joinToString()
Log.i(TAG, "One-Time Prekeys\n$oneTimePreKeys")
}) {
Toast.makeText(requireContext(), "Dumped to logs", Toast.LENGTH_SHORT).show()
}
}
}

View File

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

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Observable
import org.signal.ringrtc.CallManager
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
import org.thoughtcrime.securesms.keyvalue.InternalValues
@@ -20,6 +21,14 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
repository.getEmojiVersionInfo { version ->
store.update { it.copy(emojiVersion = version) }
}
val pendingOneTimeDonation: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.distinctUntilChanged()
.map { it.isPresent }
store.update(pendingOneTimeDonation) { pending, state ->
state.copy(hasPendingOneTimeDonation = pending)
}
}
val state: LiveData<InternalSettingsState> = store.stateLiveData
@@ -136,7 +145,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media()
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media(),
hasPendingOneTimeDonation = SignalStore.donationsValues().getPendingOneTimeDonation() != null
)
fun onClearOnboardingState() {

View File

@@ -0,0 +1,148 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Buttons
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueCodeSelector
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Configuration fragment for [TerminalDonationQueue.TerminalDonation]
*/
class InternalTerminalDonationConfigurationFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
InternalTerminalDonationConfigurationContent(
onAddClick = {
SignalStore.donationsValues().appendToTerminalDonationQueue(it)
findNavController().popBackStack()
}
)
}
}
@Preview
@Composable
private fun InternalTerminalDonationConfigurationContentPreview() {
SignalTheme {
Surface {
InternalTerminalDonationConfigurationContent(
onAddClick = {}
)
}
}
}
@Composable
private fun InternalTerminalDonationConfigurationContent(
onAddClick: (TerminalDonationQueue.TerminalDonation) -> Unit
) {
val terminalDonationState: MutableState<TerminalDonationQueue.TerminalDonation> = remember {
mutableStateOf(
TerminalDonationQueue.TerminalDonation(
level = 1000L,
isLongRunningPaymentMethod = true
)
)
}
val paymentMethodType = remember(terminalDonationState.value.isLongRunningPaymentMethod) {
if (terminalDonationState.value.isLongRunningPaymentMethod) PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT else PendingOneTimeDonation.PaymentMethodType.CARD
}
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Rows.ToggleRow(
checked = terminalDonationState.value.isLongRunningPaymentMethod,
text = "Long-running payment method",
onCheckChanged = {
terminalDonationState.value = terminalDonationState.value.copy(isLongRunningPaymentMethod = it)
}
)
}
item {
Rows.ToggleRow(
checked = terminalDonationState.value.error != null,
text = "Enable error",
onCheckChanged = {
val error = if (it) {
DonationErrorValue()
} else {
null
}
terminalDonationState.value = terminalDonationState.value.copy(error = error)
}
)
}
val error = terminalDonationState.value.error
if (error != null) {
item {
DonationErrorValueTypeSelector(
selectedPaymentMethodType = paymentMethodType,
selectedErrorType = error.type,
onErrorTypeSelected = {
terminalDonationState.value = terminalDonationState.value.copy(
error = error.copy(
type = it,
code = ""
)
)
}
)
}
item {
DonationErrorValueCodeSelector(
selectedPaymentMethodType = paymentMethodType,
selectedErrorType = error.type,
selectedErrorCode = error.code,
onErrorCodeSelected = {
terminalDonationState.value = terminalDonationState.value.copy(
error = error.copy(
code = it
)
)
}
)
}
}
item {
Buttons.LargeTonal(
onClick = { onAddClick(terminalDonationState.value) },
modifier = Modifier.defaultMinSize(minWidth = 220.dp)
) {
Text(text = "Confirm")
}
}
}
}

View File

@@ -0,0 +1,181 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.internal.donor
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import org.signal.donations.StripeDeclineCode
import org.signal.donations.StripeFailureCode
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
/**
* Displays a dropdown widget for selecting an error type.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DonationErrorValueTypeSelector(
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
selectedErrorType: DonationErrorValue.Type,
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit
) {
var expanded by remember {
mutableStateOf(false)
}
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
value = selectedErrorType.name,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DonationErrorValue.Type.values().filterNot {
selectedPaymentMethodType == PendingOneTimeDonation.PaymentMethodType.PAYPAL && it == DonationErrorValue.Type.FAILURE_CODE
}.forEach { item ->
DropdownMenuItem(
text = { Text(text = item.name) },
onClick = {
onErrorTypeSelected(item)
expanded = false
}
)
}
}
}
}
/**
* Displays a dropdown widget for selecting an error code, if the corresponding type
* allows for such things.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DonationErrorValueCodeSelector(
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
selectedErrorType: DonationErrorValue.Type,
selectedErrorCode: String,
onErrorCodeSelected: (String) -> Unit
) {
val isCodedError = remember(selectedErrorType) {
selectedErrorType in setOf(DonationErrorValue.Type.PROCESSOR_CODE, DonationErrorValue.Type.DECLINE_CODE, DonationErrorValue.Type.FAILURE_CODE)
}
var expanded by remember {
mutableStateOf(false)
}
if (isCodedError) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
TextField(
value = selectedErrorCode,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.menuAnchor()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
when (selectedErrorType) {
DonationErrorValue.Type.PROCESSOR_CODE -> {
ProcessorErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
}
DonationErrorValue.Type.DECLINE_CODE -> {
DeclineCodeErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
}
DonationErrorValue.Type.FAILURE_CODE -> {
FailureCodeErrorsDropdown(onErrorCodeSelected)
}
else -> error("This should never happen")
}
}
}
}
}
@Composable
private fun ProcessorErrorsDropdown(
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
onErrorCodeSelected: (String) -> Unit
) {
val values = when (paymentMethodType) {
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> arrayOf("2046", "2074")
else -> arrayOf("currency_not_supported", "call_issuer")
}
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
}
@Composable
private fun DeclineCodeErrorsDropdown(
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
onErrorCodeSelected: (String) -> Unit
) {
val values = remember(paymentMethodType) {
when (paymentMethodType) {
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> PayPalDeclineCode.KnownCode.values()
else -> StripeDeclineCode.Code.values()
}.map { it.name }.toTypedArray()
}
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
}
@Composable
private fun FailureCodeErrorsDropdown(
onErrorCodeSelected: (String) -> Unit
) {
val values = remember {
StripeFailureCode.Code.values().map { it.name }.toTypedArray()
}
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
}
@Composable
private fun ValuesDropdown(values: Array<String>, onErrorCodeSelected: (String) -> Unit) {
values.forEach { item ->
DropdownMenuItem(
text = { Text(text = item) },
onClick = {
onErrorCodeSelected(item)
}
)
}
}

View File

@@ -124,7 +124,7 @@ fun SvrPlaygroundScreenLightTheme() {
Surface {
SvrPlaygroundScreen(
state = InternalSvrPlaygroundState(
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
options = persistentListOf(SvrImplementation.SVR2)
)
)
}
@@ -138,7 +138,7 @@ fun SvrPlaygroundScreenDarkTheme() {
Surface {
SvrPlaygroundScreen(
state = InternalSvrPlaygroundState(
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
options = persistentListOf(SvrImplementation.SVR2)
)
)
}

View File

@@ -13,5 +13,5 @@ data class InternalSvrPlaygroundState(
enum class SvrImplementation(
val title: String
) {
SVR1("KBS"), SVR2("SVR2")
SVR2("SVR2")
}

View File

@@ -19,13 +19,12 @@ import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV1
class InternalSvrPlaygroundViewModel : ViewModel() {
private val _state: MutableState<InternalSvrPlaygroundState> = mutableStateOf(
InternalSvrPlaygroundState(
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
options = persistentListOf(SvrImplementation.SVR2)
)
)
val state: State<InternalSvrPlaygroundState> = _state
@@ -104,7 +103,6 @@ class InternalSvrPlaygroundViewModel : ViewModel() {
private fun SvrImplementation.toImplementation(): SecureValueRecovery {
return when (this) {
SvrImplementation.SVR1 -> SecureValueRecoveryV1(ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE))
SvrImplementation.SVR2 -> ApplicationDependencies.getSignalServiceAccountManager().getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
}
}

View File

@@ -106,7 +106,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
actionId = R.string.NotificationSettingsFragment__turn_on,
onClick = {
TurnOnNotificationsBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(requireContext()).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
)
)

View File

@@ -25,8 +25,6 @@ import org.signal.core.ui.Texts
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberSharingMode
class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
@@ -67,7 +65,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
item {
Rows.RadioRow(
selected = state.seeMyPhoneNumber == PhoneNumberSharingMode.EVERYONE,
selected = state.phoneNumberSharing,
text = stringResource(id = R.string.PhoneNumberPrivacy_everyone),
modifier = Modifier.clickable(onClick = viewModel::setEveryoneCanSeeMyNumber)
)
@@ -75,7 +73,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
item {
Rows.RadioRow(
selected = state.seeMyPhoneNumber == PhoneNumberSharingMode.NOBODY,
selected = !state.phoneNumberSharing,
text = stringResource(id = R.string.PhoneNumberPrivacy_nobody),
modifier = Modifier.clickable(onClick = viewModel::setNobodyCanSeeMyNumber)
)
@@ -84,10 +82,10 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
item {
Text(
text = stringResource(
id = when (state.seeMyPhoneNumber) {
PhoneNumberSharingMode.EVERYONE -> R.string.PhoneNumberPrivacySettingsFragment__your_phone_number
PhoneNumberSharingMode.NOBODY -> R.string.PhoneNumberPrivacySettingsFragment__nobody_will_see
else -> error("Unexpected state $state")
id = if (state.phoneNumberSharing) {
R.string.PhoneNumberPrivacySettingsFragment__your_phone_number
} else {
R.string.PhoneNumberPrivacySettingsFragment__nobody_will_see
}
),
style = MaterialTheme.typography.bodyMedium,
@@ -106,16 +104,16 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
item {
Rows.RadioRow(
selected = state.findMeByPhoneNumber == PhoneNumberListingMode.LISTED,
selected = state.discoverableByPhoneNumber,
text = stringResource(id = R.string.PhoneNumberPrivacy_everyone),
modifier = Modifier.clickable(onClick = viewModel::setEveryoneCanFindMeByMyNumber)
)
}
if (state.seeMyPhoneNumber == PhoneNumberSharingMode.NOBODY) {
if (!state.phoneNumberSharing) {
item {
Rows.RadioRow(
selected = state.findMeByPhoneNumber == PhoneNumberListingMode.UNLISTED,
selected = !state.discoverableByPhoneNumber,
text = stringResource(id = R.string.PhoneNumberPrivacy_nobody),
modifier = Modifier.clickable(onClick = viewModel::setNobodyCanFindMeByMyNumber)
)
@@ -125,9 +123,10 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
item {
Text(
text = stringResource(
id = when (state.findMeByPhoneNumber) {
PhoneNumberListingMode.UNLISTED -> R.string.WhoCanSeeMyPhoneNumberFragment__nobody_on_signal
PhoneNumberListingMode.LISTED -> R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has
id = if (state.discoverableByPhoneNumber) {
R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has
} else {
R.string.WhoCanSeeMyPhoneNumberFragment__nobody_on_signal
}
),
style = MaterialTheme.typography.bodyMedium,

View File

@@ -1,8 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.privacy.pnp
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
data class PhoneNumberPrivacySettingsState(
val seeMyPhoneNumber: PhoneNumberPrivacyValues.PhoneNumberSharingMode,
val findMeByPhoneNumber: PhoneNumberPrivacyValues.PhoneNumberListingMode
val phoneNumberSharing: Boolean,
val discoverableByPhoneNumber: Boolean
)

View File

@@ -17,39 +17,39 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
private val _state = mutableStateOf(
PhoneNumberPrivacySettingsState(
seeMyPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode,
findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode
phoneNumberSharing = SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled,
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().isDiscoverableByPhoneNumber
)
)
val state: State<PhoneNumberPrivacySettingsState> = _state
fun setNobodyCanSeeMyNumber() {
setPhoneNumberSharingMode(PhoneNumberSharingMode.NOBODY)
setPhoneNumberSharingEnabled(false)
}
fun setEveryoneCanSeeMyNumber() {
setPhoneNumberSharingMode(PhoneNumberSharingMode.EVERYONE)
setPhoneNumberListingMode(PhoneNumberListingMode.LISTED)
setPhoneNumberSharingEnabled(true)
setDiscoverableByPhoneNumber(true)
}
fun setNobodyCanFindMeByMyNumber() {
setPhoneNumberListingMode(PhoneNumberListingMode.UNLISTED)
setDiscoverableByPhoneNumber(false)
}
fun setEveryoneCanFindMeByMyNumber() {
setPhoneNumberListingMode(PhoneNumberListingMode.LISTED)
setDiscoverableByPhoneNumber(true)
}
private fun setPhoneNumberSharingMode(phoneNumberSharingMode: PhoneNumberSharingMode) {
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = phoneNumberSharingMode
private fun setPhoneNumberSharingEnabled(phoneNumberSharingEnabled: Boolean) {
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = if (phoneNumberSharingEnabled) PhoneNumberSharingMode.EVERYBODY else PhoneNumberSharingMode.NOBODY
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
StorageSyncHelper.scheduleSyncForDataChange()
refresh()
}
private fun setPhoneNumberListingMode(phoneNumberListingMode: PhoneNumberListingMode) {
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = phoneNumberListingMode
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (discoverable) PhoneNumberListingMode.LISTED else PhoneNumberListingMode.UNLISTED
StorageSyncHelper.scheduleSyncForDataChange()
ApplicationDependencies.getJobManager().startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
refresh()
@@ -57,8 +57,8 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
fun refresh() {
_state.value = PhoneNumberPrivacySettingsState(
seeMyPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode,
findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode
phoneNumberSharing = SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled,
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().isDiscoverableByPhoneNumber
)
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import org.thoughtcrime.securesms.badges.BadgeImageView
import org.thoughtcrime.securesms.badges.models.Badge
enum class BadgeImageSize(val sizeCode: Int) {
SMALL(0),
MEDIUM(1),
LARGE(2),
X_LARGE(3),
BADGE_64(4),
BADGE_112(5)
}
@Composable
fun BadgeImage112(
badge: Badge?,
modifier: Modifier = Modifier
) {
if (LocalInspectionMode.current) {
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
} else {
AndroidView(
factory = {
BadgeImageView(it, BadgeImageSize.BADGE_112)
},
update = {
it.setBadge(badge)
},
modifier = modifier
)
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription
import android.content.DialogInterface
import android.net.Uri
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.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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 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.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.donate.DonateToSignalType
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
/**
* Displayed after the user completes the donation flow for a bank transfer.
*/
class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
private val args: DonationPendingBottomSheetArgs by navArgs()
@Composable
override fun SheetContent() {
DonationPendingBottomSheetContent(
badge = args.request.badge,
onDoneClick = this::onDoneClick
)
}
private fun onDoneClick() {
dismissAllowingStateLoss()
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (args.request.donateToSignalType == DonateToSignalType.ONE_TIME) {
findNavController().popBackStack()
} else {
requireActivity().finish()
requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
}
}
}
@Preview
@Composable
fun DonationPendingBottomSheetContentPreview() {
SignalTheme {
Surface {
DonationPendingBottomSheetContent(
badge = Badge(
id = "",
category = Badge.Category.Donor,
name = "Signal Star",
description = "",
imageUrl = Uri.EMPTY,
imageDensity = "",
expirationTimestamp = 0L,
visible = true,
duration = 0L
),
onDoneClick = {}
)
}
}
}
@Composable
private fun DonationPendingBottomSheetContent(
badge: Badge,
onDoneClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 44.dp)
) {
BottomSheets.Handle()
BadgeImage112(
badge = badge,
modifier = Modifier
.padding(top = 21.dp, bottom = 16.dp)
.size(80.dp)
)
Text(
text = stringResource(id = R.string.DonationPendingBottomSheet__donation_pending),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 8.dp)
)
val textResource = if (badge.isSubscription()) {
R.string.DonationPendingBottomSheet__your_monthly_donation_is_pending
} else {
R.string.DonationPendingBottomSheet__your_one_time_donation_is_pending
}
Text(
text = stringResource(id = textResource, badge.name),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 20.dp)
)
val context = LocalContext.current
val learnMore = stringResource(id = R.string.DonationPendingBottomSheet__learn_more)
val fullString = stringResource(id = R.string.DonationPendingBottomSheet__bank_transfers_usually_take, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.pending_transfer_url))
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
style = LocalTextStyle.current.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
modifier = Modifier.padding(bottom = 48.dp)
)
Buttons.LargeTonal(
onClick = onDoneClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(text = stringResource(id = R.string.DonationPendingBottomSheet__done))
}
}
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription
import okio.ByteString
import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.DecimalValue
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
import java.math.BigDecimal
import java.math.BigInteger
import java.math.MathContext
import java.util.Currency
object DonationSerializationHelper {
fun createPendingOneTimeDonationProto(
badge: Badge,
paymentSourceType: PaymentSourceType,
amount: FiatMoney
): PendingOneTimeDonation {
return PendingOneTimeDonation(
badge = Badges.toDatabaseBadge(badge),
paymentMethodType = when (paymentSourceType) {
PaymentSourceType.PayPal -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
PaymentSourceType.Stripe.CreditCard, PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Unknown -> PendingOneTimeDonation.PaymentMethodType.CARD
PaymentSourceType.Stripe.SEPADebit -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
PaymentSourceType.Stripe.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
},
amount = amount.toFiatValue(),
timestamp = System.currentTimeMillis()
)
}
fun FiatValue.toFiatMoney(): FiatMoney {
return FiatMoney(
amount!!.toBigDecimal(),
Currency.getInstance(currencyCode)
)
}
fun DecimalValue.toBigDecimal(): BigDecimal {
return BigDecimal(
BigInteger(value_.toByteArray()),
scale,
MathContext(precision)
)
}
fun FiatMoney.toFiatValue(): FiatValue {
return FiatValue(
currencyCode = currency.currencyCode,
amount = amount.toDecimalValue()
)
}
fun BigDecimal.toDecimalValue(): DecimalValue {
return DecimalValue(
scale = scale(),
precision = precision(),
value_ = ByteString.of(*this.unscaledValue().toByteArray())
)
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LocaleFeatureFlags
@@ -20,7 +21,7 @@ object InAppDonations {
* - Able to use PayPal and is in a region where it is able to be accepted.
*/
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable()
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() || isIDEALAvailable()
}
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
@@ -28,7 +29,8 @@ object InAppDonations {
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailable()
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(donateToSignalType)
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(donateToSignalType)
PaymentSourceType.Unknown -> false
}
}
@@ -65,6 +67,29 @@ object InAppDonations {
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number.
*/
fun isSEPADebitAvailable(): Boolean {
return FeatureFlags.sepaDebitDonations()
return Environment.IS_STAGING || (FeatureFlags.sepaDebitDonations() && LocaleFeatureFlags.isSepaEnabled())
}
/**
* Whether the user is in a region which supports IDEAL transfers, based off local phone number.
*/
fun isIDEALAvailable(): Boolean {
return Environment.IS_STAGING || (FeatureFlags.idealDonations() && LocaleFeatureFlags.isIdealEnabled())
}
/**
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number
* and donation type.
*/
fun isSEPADebitAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && isSEPADebitAvailable()
}
/**
* Whether the user is in a region which suports IDEAL transfers, based off local phone number and
* donation type
*/
fun isIDEALAvailbleForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
return donateToSignalType != DonateToSignalType.GIFT && isIDEALAvailable()
}
}

View File

@@ -5,9 +5,11 @@ import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
@@ -147,7 +149,10 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
}
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
fun setSubscriptionLevel(gatewayRequest: GatewayRequest, isLongRunning: Boolean): Completable {
val subscriptionLevel = gatewayRequest.level.toString()
val uiSessionKey = gatewayRequest.uiSessionKey
return getOrCreateLevelUpdateOperation(subscriptionLevel)
.flatMapCompletable { levelUpdateOperation ->
val subscriber = SignalStore.donationsValues().requireSubscriber()
@@ -186,13 +191,24 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, terminalDonation).enqueue { _, jobState ->
if (jobState.isComplete) {
finalJobState = jobState
countDownLatch.countDown()
}
}
val timeoutError: DonationError = if (isLongRunning) {
DonationError.donationPending(DonationErrorSource.MONTHLY, gatewayRequest)
} else {
DonationError.timeoutWaitingForToken(DonationErrorSource.MONTHLY)
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
@@ -202,20 +218,20 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
JobTracker.JobState.FAILURE -> {
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY))
}
else -> {
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(timeoutError)
}
}
} else {
Log.d(TAG, "Subscription request response job timed out.", true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(timeoutError)
}
} catch (e: InterruptedException) {
Log.w(TAG, "Subscription request response interrupted.", e, true)
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
it.onError(timeoutError)
}
}
}.doOnError {
@@ -224,22 +240,28 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
}
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
if (levelUpdateOperation == null) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
}
SignalStore.donationsValues().setLevelOperation(newOperation)
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Created a new operation for $subscriptionLevel")
newOperation
} else {
LevelUpdate.updateProcessingState(true)
Log.d(TAG, "Reusing operation for $subscriptionLevel")
levelUpdateOperation
companion object {
fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation {
Log.d(tag, "Retrieving level update operation for $subscriptionLevel")
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
return if (levelUpdateOperation == null) {
val newOperation = LevelUpdateOperation(
idempotencyKey = IdempotencyKey.generate(),
level = subscriptionLevel
)
SignalStore.donationsValues().setLevelOperation(newOperation)
LevelUpdate.updateProcessingState(true)
Log.d(tag, "Created a new operation for $subscriptionLevel")
newOperation
} else {
LevelUpdate.updateProcessingState(true)
Log.d(tag, "Reusing operation for $subscriptionLevel")
levelUpdateOperation
}
}
}

View File

@@ -8,14 +8,17 @@ import org.signal.core.util.money.FiatMoney
import org.signal.donations.PaymentSourceType
import org.thoughtcrime.securesms.badges.models.Badge
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.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.ProfileUtil
@@ -38,7 +41,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
Single.error(throwable)
} else {
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
}
}
@@ -106,22 +109,20 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
}
fun waitForOneTimeRedemption(
price: FiatMoney,
gatewayRequest: GatewayRequest,
paymentIntentId: String,
badgeRecipient: RecipientId,
additionalMessage: String?,
badgeLevel: Long,
donationProcessor: DonationProcessor,
uiSessionKey: Long
paymentSourceType: PaymentSourceType
): Completable {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit
val isBoost = gatewayRequest.recipientId == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
val waitOnRedemption = Completable.create {
val donationReceiptRecord = if (isBoost) {
DonationReceiptRecord.createForBoost(price)
DonationReceiptRecord.createForBoost(gatewayRequest.fiat)
} else {
DonationReceiptRecord.createForGift(price)
DonationReceiptRecord.createForGift(gatewayRequest.fiat)
}
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
@@ -129,12 +130,25 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
SignalStore.donationsValues().setPendingOneTimeDonation(
DonationSerializationHelper.createPendingOneTimeDonationProto(
gatewayRequest.badge,
paymentSourceType,
gatewayRequest.fiat
)
)
val terminalDonation = TerminalDonationQueue.TerminalDonation(
level = gatewayRequest.level,
isLongRunningPaymentMethod = isLongRunning
)
val countDownLatch = CountDownLatch(1)
var finalJobState: JobTracker.JobState? = null
val chain = if (isBoost) {
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
} else {
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
}
chain.enqueue { _, jobState ->
@@ -144,6 +158,12 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
}
}
val timeoutError: DonationError = if (isLongRunning) {
DonationError.donationPending(donationErrorSource, gatewayRequest)
} else {
DonationError.timeoutWaitingForToken(donationErrorSource)
}
try {
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
when (finalJobState) {
@@ -157,16 +177,16 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
}
else -> {
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(timeoutError)
}
}
} else {
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(timeoutError)
}
} catch (e: InterruptedException) {
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
it.onError(timeoutError)
}
}

View File

@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.storage.StorageSyncHelper
@@ -47,7 +46,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
fun isGooglePayAvailable(): Completable {
@@ -101,7 +100,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}
.flatMap { result ->
val recipient = Recipient.resolved(badgeRecipient)
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
Log.d(TAG, "Created payment intent for $price.", true)
when (result) {
@@ -131,7 +130,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
badgeRecipient: RecipientId
): Single<StripeApi.Secure3DSAction> {
val isBoost = badgeRecipient == Recipient.self().id
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
Log.d(TAG, "Confirming payment intent...", true)
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
@@ -203,19 +202,22 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
* expect an error later in the chain to inform us of this.
*/
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
fun getStatusAndPaymentMethodId(
stripeIntentAccessor: StripeIntentAccessor,
paymentMethodId: String?
): Single<StatusAndPaymentMethodId> {
return Single.fromCallable {
when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId)
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
if (it.status == null) {
Log.d(TAG, "Returned payment intent had a null status.", true)
}
StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
}
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
StatusAndPaymentMethodId(it.status, it.paymentMethod)
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethod)
}
}
}
@@ -223,6 +225,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
fun setDefaultPaymentMethod(
paymentMethodId: String,
setupIntentId: String,
paymentSourceType: PaymentSourceType
): Completable {
return Single.fromCallable {
@@ -231,9 +234,15 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
}.flatMap {
Log.d(TAG, "Setting default payment method via Signal service...")
Single.fromCallable {
ApplicationDependencies
.getDonationsService()
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
ApplicationDependencies
.getDonationsService()
.setDefaultIdealPaymentMethod(it.subscriberId, setupIntentId)
} else {
ApplicationDependencies
.getDonationsService()
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
}
}
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
Log.d(TAG, "Set default payment method via Signal service!")
@@ -257,7 +266,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
}
fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single<StripeApi.PaymentSource> {
Log.d(TAG, "Creating iDEAL payment source via Stripe api...")
return stripeApi.createPaymentSourceFromIDEALData(idealData)
}
data class StatusAndPaymentMethodId(
val intentId: String,
val status: StripeIntentStatus,
val paymentMethod: String?
)

View File

@@ -0,0 +1,334 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import android.content.DialogInterface
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
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.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.util.viewModel
/**
* Bottom Sheet displayed when the app notices that a long-running donation has
* completed.
*/
class TerminalDonationBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
private const val ARG_DONATION_COMPLETED = "arg.donation.completed"
@JvmStatic
fun show(fragmentManager: FragmentManager, terminalDonation: TerminalDonationQueue.TerminalDonation) {
TerminalDonationBottomSheet().apply {
arguments = bundleOf(
ARG_DONATION_COMPLETED to terminalDonation.encode()
)
show(fragmentManager, null)
}
}
}
override val peekHeightPercentage: Float = 1f
private val terminalDonation: TerminalDonationQueue.TerminalDonation by lazy(LazyThreadSafetyMode.NONE) {
TerminalDonationQueue.TerminalDonation.ADAPTER.decode(requireArguments().getByteArray(ARG_DONATION_COMPLETED)!!)
}
private val viewModel: TerminalDonationViewModel by viewModel {
TerminalDonationViewModel(terminalDonation, badgeRepository = BadgeRepository(requireContext()))
}
@Composable
override fun SheetContent() {
if (terminalDonation.error != null) {
PaymentFailureBottomSheet()
} else {
CompletedSheet()
}
}
@Composable
private fun PaymentFailureBottomSheet() {
val badge by viewModel.badge
DonationPaymentFailureBottomSheet(
badge = badge,
onTryAgainClick = {
startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
},
onNotNowClick = {
dismissAllowingStateLoss()
}
)
}
@Composable
private fun CompletedSheet() {
val badge by viewModel.badge
val isToggleChecked by viewModel.isToggleChecked
val toggleType by viewModel.toggleType
DonationCompletedSheetContent(
badge = badge,
isToggleChecked = isToggleChecked,
toggleType = toggleType,
onCheckChanged = viewModel::onToggleCheckChanged,
onDoneClick = { dismissAllowingStateLoss() }
)
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
viewModel.commitToggleState()
}
}
@Preview
@Composable
private fun DonationPaymentFailureBottomSheet() {
SignalTheme {
Surface {
DonationPaymentFailureBottomSheet(
badge = null,
onTryAgainClick = {},
onNotNowClick = {}
)
}
}
}
@Composable
private fun DonationPaymentFailureBottomSheet(
badge: Badge?,
onTryAgainClick: () -> Unit,
onNotNowClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
BottomSheets.Handle()
Box(
modifier = Modifier
.padding(top = 21.dp, bottom = 16.dp)
) {
BadgeImage112(
badge = badge,
modifier = Modifier
.size(80.dp)
)
Box(
modifier = Modifier
.size(24.dp)
.padding(2.dp)
.background(
color = MaterialTheme.colorScheme.background,
shape = CircleShape
)
.align(Alignment.TopEnd)
)
Icon(
painter = painterResource(id = R.drawable.symbol_error_circle_fill_24),
tint = MaterialTheme.colorScheme.error,
contentDescription = null,
modifier = Modifier.align(Alignment.TopEnd)
)
}
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__donation_couldnt_be_processed),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
)
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__were_having_trouble),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 45.dp)
)
Buttons.LargeTonal(
onClick = onTryAgainClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 32.dp, bottom = 16.dp)
) {
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__try_again)
)
}
TextButton(
onClick = onNotNowClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 56.dp)
) {
Text(
text = stringResource(id = R.string.DonationErrorBottomSheet__not_now)
)
}
}
}
@Preview
@Composable
private fun DonationCompletedSheetContentPreview() {
SignalTheme {
Surface {
DonationCompletedSheetContent(
badge = null,
isToggleChecked = false,
toggleType = TerminalDonationViewModel.ToggleType.NONE,
onCheckChanged = {},
onDoneClick = {}
)
}
}
}
@Composable
private fun DonationCompletedSheetContent(
badge: Badge?,
isToggleChecked: Boolean,
toggleType: TerminalDonationViewModel.ToggleType,
onCheckChanged: (Boolean) -> Unit,
onDoneClick: () -> Unit
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
BottomSheets.Handle()
BadgeImage112(
badge = badge,
modifier = Modifier
.padding(top = 21.dp, bottom = 16.dp)
.size(80.dp)
)
Text(
text = stringResource(id = R.string.DonationCompletedBottomSheet__donation_complete),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 45.dp)
)
Text(
text = stringResource(id = R.string.DonationCompleteBottomSheet__your_bank_transfer_was_received),
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 24.dp)
.padding(horizontal = 45.dp)
)
if (toggleType == TerminalDonationViewModel.ToggleType.NONE) {
CircularProgressIndicator()
} else {
DonationToggleRow(
checked = isToggleChecked,
text = stringResource(id = toggleType.copyId),
onCheckChanged = onCheckChanged
)
}
Buttons.LargeTonal(
onClick = onDoneClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(top = 48.dp, bottom = 56.dp)
) {
Text(
text = stringResource(id = R.string.DonationPendingBottomSheet__done)
)
}
}
}
@Composable
private fun DonationToggleRow(
checked: Boolean,
text: String,
onCheckChanged: (Boolean) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(12.dp)
)
.padding(horizontal = 16.dp)
) {
Text(
text = text,
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
.padding(vertical = 16.dp)
)
Switch(
checked = checked,
onCheckedChange = onCheckChanged,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
}

View File

@@ -0,0 +1,49 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import androidx.fragment.app.FragmentManager
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.thanks.ThanksForYourSupportBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Handles displaying the "Thank You" or "Donation completed" sheet when the user navigates to an appropriate screen.
* These sheets are one-shot.
*/
class TerminalDonationDelegate(
private val fragmentManager: FragmentManager,
private val lifecycleOwner: LifecycleOwner
) : DefaultLifecycleObserver {
private val lifecycleDisposable = LifecycleDisposable().apply {
bindTo(lifecycleOwner)
}
private val badgeRepository = TerminalDonationRepository()
override fun onResume(owner: LifecycleOwner) {
val donations = SignalStore.donationsValues().consumeTerminalDonations()
for (donation in donations) {
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
TerminalDonationBottomSheet.show(fragmentManager, donation)
} else {
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
sheet.arguments = args
sheet.show(fragmentManager, null)
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.whispersystems.signalservice.api.services.DonationsService
import java.util.Locale
class TerminalDonationRepository(
private val donationsService: DonationsService = ApplicationDependencies.getDonationsService()
) {
fun getBadge(terminalDonation: TerminalDonationQueue.TerminalDonation): Single<Badge> {
return Single
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
.flatMap { it.flattenResult() }
.map { it.levels[terminalDonation.level.toInt()]!! }
.map { Badges.fromServiceBadge(it.badge) }
.subscribeOn(Schedulers.io())
}
}

View File

@@ -0,0 +1,111 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
import android.annotation.SuppressLint
import androidx.annotation.StringRes
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.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.BadgeRepository
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
class TerminalDonationViewModel(
donationCompleted: TerminalDonationQueue.TerminalDonation,
repository: TerminalDonationRepository = TerminalDonationRepository(),
private val badgeRepository: BadgeRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(TerminalDonationViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val internalBadge = mutableStateOf<Badge?>(null)
private val internalToggleChecked = mutableStateOf(false)
private val internalToggleType = mutableStateOf(ToggleType.NONE)
val badge: State<Badge?> = internalBadge
val isToggleChecked: State<Boolean> = internalToggleChecked
val toggleType: State<ToggleType> = internalToggleType
init {
disposables += repository.getBadge(donationCompleted)
.map { badge ->
val hasOtherBadges = Recipient.self().badges.filterNot { it.id == badge.id }.isNotEmpty()
val isDisplayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
val toggleType = when {
hasOtherBadges && isDisplayingBadges -> ToggleType.MAKE_FEATURED_BADGE
else -> ToggleType.DISPLAY_ON_PROFILE
}
badge to toggleType
}
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = { (badge, toggleType) ->
internalBadge.value = badge
internalToggleType.value = toggleType
}
)
}
fun onToggleCheckChanged(isChecked: Boolean) {
internalToggleChecked.value = isChecked
}
/**
* Note that the intention here is that these are able to complete outside of the scope of the ViewModel's lifecycle.
*/
@SuppressLint("CheckResult")
fun commitToggleState() {
when (toggleType.value) {
ToggleType.NONE -> Unit
ToggleType.MAKE_FEATURED_BADGE -> {
badgeRepository.setVisibilityForAllBadges(isToggleChecked.value).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating badge visibility", it)
}
)
}
ToggleType.DISPLAY_ON_PROFILE -> {
val badge = this.badge.value
if (badge == null) {
Log.w(TAG, "No badge!")
return
}
badgeRepository.setFeaturedBadge(badge).subscribeBy(
onError = {
Log.w(TAG, "Failure while updating featured badge", it)
}
)
}
}
}
override fun onCleared() {
disposables.clear()
}
enum class ToggleType(@StringRes val copyId: Int) {
NONE(-1),
MAKE_FEATURED_BADGE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge),
DISPLAY_ON_PROFILE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
}
}

View File

@@ -6,5 +6,5 @@ sealed class DonateToSignalAction {
data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List<String>) : DonateToSignalAction()
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
data class UpdateSubscription(val gatewayRequest: GatewayRequest, val isLongRunning: Boolean) : DonateToSignalAction()
}

View File

@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
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.donate.gateway.GatewayResponse
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
@@ -40,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
/**
@@ -106,7 +108,7 @@ class DonateToSignalFragment :
}
override fun bindAdapter(adapter: MappingAdapter) {
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.ONE_TIME, DonationErrorSource.MONTHLY)
val recyclerView = this.recyclerView!!
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
@@ -141,12 +143,14 @@ class DonateToSignalFragment :
findNavController().safeNavigate(navAction)
}
is DonateToSignalAction.DisplayGatewaySelectorDialog -> {
Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}")
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest)
findNavController().safeNavigate(navAction)
}
is DonateToSignalAction.CancelSubscription -> {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
@@ -155,6 +159,7 @@ class DonateToSignalFragment :
)
)
}
is DonateToSignalAction.UpdateSubscription -> {
findNavController().safeNavigate(
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
@@ -229,7 +234,6 @@ class DonateToSignalFragment :
customPref(
DonationPillToggle.Model(
isEnabled = state.areFieldsEnabled,
selected = state.donateToSignalType,
onClick = {
viewModel.toggleDonationType()
@@ -252,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()
}
}
)
@@ -278,28 +286,58 @@ 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 {
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 {
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())
@@ -333,11 +371,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() })
@@ -417,8 +450,12 @@ class DonateToSignalFragment :
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(gatewayRequest))
}
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse))
}
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
@@ -432,4 +469,8 @@ class DonateToSignalFragment :
override fun onUserCancelledPaymentFlow() {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
}
}

View File

@@ -4,6 +4,8 @@ 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.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
@@ -20,7 +22,7 @@ data class DonateToSignalState(
val areFieldsEnabled: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
@@ -33,7 +35,7 @@ data class DonateToSignalState(
val canSetCurrency: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> areFieldsEnabled
DonateToSignalType.ONE_TIME -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
@@ -59,13 +61,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
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val canUpdate: Boolean
get() = when (donateToSignalType) {
DonateToSignalType.ONE_TIME -> false
@@ -73,6 +82,9 @@ data class DonateToSignalState(
DonateToSignalType.GIFT -> error("This flow does not support gifts")
}
val isUpdateLongRunning: Boolean
get() = monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT
data class OneTimeDonationState(
val badge: Badge? = null,
val selectedCurrency: Currency = SignalStore.donationsValues().getOneTimeCurrency(),
@@ -82,6 +94,8 @@ data class DonateToSignalState(
val isCustomAmountFocused: Boolean = false,
val donationStage: DonationStage = DonationStage.INIT,
val selectableCurrencyCodes: List<String> = emptyList(),
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isPending(),
val isOneTimeDonationLongRunning: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isLongRunning(),
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
) {
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)

View File

@@ -12,8 +12,8 @@ enum class DonateToSignalType(val requestCode: Short) : Parcelable {
fun toErrorSource(): DonationErrorSource {
return when (this) {
ONE_TIME -> DonationErrorSource.BOOST
MONTHLY -> DonationErrorSource.SUBSCRIPTION
ONE_TIME -> DonationErrorSource.ONE_TIME
MONTHLY -> DonationErrorSource.MONTHLY
GIFT -> DonationErrorSource.GIFT
}
}

View File

@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDo
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.SubscriptionRedemptionJobWatcher
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
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
@@ -106,7 +107,7 @@ class DonateToSignalViewModel(
fun updateSubscription() {
val snapshot = store.state
if (snapshot.areFieldsEnabled) {
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot)))
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot), snapshot.isUpdateLongRunning))
}
}
@@ -207,6 +208,26 @@ 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)
}.distinctUntilChanged()
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
.map { pending -> pending.filter { !it.isExpired }.isPresent }
.distinctUntilChanged()
oneTimeDonationDisposables += Observable
.combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b }
.subscribe { hasPendingOneTimeDonation ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) }
}
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
onSuccess = { badge ->
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
@@ -274,7 +295,7 @@ class DonateToSignalViewModel(
}
private fun monitorLevelUpdateProcessing() {
val isTransactionJobInProgress: Observable<Boolean> = SubscriptionRedemptionJobWatcher.watch().map {
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
it.map { jobState ->
when (jobState) {
JobTracker.JobState.PENDING -> true

View File

@@ -30,10 +30,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
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.errors.DonationError
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
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.util.fragments.requireListener
import java.util.Currency
@@ -89,6 +91,16 @@ class DonationCheckoutDelegate(
handleDonationProcessorActionResult(result)
}
fragment.setFragmentResultListener(BankTransferRequestKeys.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
handleDonationProcessorActionResult(result)
}
fragment.setFragmentResultListener(BankTransferRequestKeys.PENDING_KEY) { _, bundle ->
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, GatewayRequest::class.java)!!
callback.navigateToDonationPending(gatewayRequest = request)
}
fragment.setFragmentResultListener(PayPalPaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(PayPalPaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
handleDonationProcessorActionResult(result)
@@ -101,7 +113,8 @@ class DonationCheckoutDelegate(
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
GatewayResponse.Gateway.SEPA_DEBIT -> launchBankTransfer(gatewayResponse)
GatewayResponse.Gateway.IDEAL -> launchBankTransfer(gatewayResponse)
}
} else {
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
@@ -121,6 +134,7 @@ class DonationCheckoutDelegate(
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
} else {
SignalStore.donationsValues().removeTerminalDonation(result.request.level)
callback.onPaymentComplete(result.request)
}
}
@@ -156,8 +170,12 @@ class DonationCheckoutDelegate(
callback.navigateToCreditCardForm(gatewayResponse.request)
}
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
callback.navigateToBankTransferMandate(gatewayResponse.request)
private fun launchBankTransfer(gatewayResponse: GatewayResponse) {
if (gatewayResponse.request.donateToSignalType != DonateToSignalType.MONTHLY && gatewayResponse.gateway == GatewayResponse.Gateway.IDEAL) {
callback.navigateToIdealDetailsFragment(gatewayResponse.request)
} else {
callback.navigateToBankTransferMandate(gatewayResponse)
}
}
private fun registerGooglePayCallback() {
@@ -188,8 +206,8 @@ class DonationCheckoutDelegate(
val error = DonationError.getGooglePayRequestTokenError(
source = when (request.donateToSignalType) {
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
},
throwable = googlePayException
@@ -210,11 +228,11 @@ class DonationCheckoutDelegate(
private var fragment: Fragment? = null
private var errorDialog: DialogInterface? = null
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
private var errorHandlerCallback: ErrorHandlerCallback? = null
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
fun attach(fragment: Fragment, errorHandlerCallback: ErrorHandlerCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
this.fragment = fragment
this.userCancelledFlowCallback = userCancelledFlowCallback
this.errorHandlerCallback = errorHandlerCallback
val disposables = LifecycleDisposable()
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
@@ -231,7 +249,7 @@ class DonationCheckoutDelegate(
override fun onDestroy(owner: LifecycleOwner) {
errorDialog?.dismiss()
fragment = null
userCancelledFlowCallback = null
errorHandlerCallback = null
}
private fun registerErrorSource(errorSource: DonationErrorSource): Disposable {
@@ -262,25 +280,47 @@ class DonationCheckoutDelegate(
return
}
if (throwable is DonationError.UserLaunchedExternalApplication) {
Log.d(TAG, "User launched an external application.", true)
return
}
if (throwable is DonationError.BadgeRedemptionError.DonationPending) {
Log.d(TAG, "Long-running donation is still pending.", true)
errorHandlerCallback?.navigateToDonationPending(throwable.gatewayRequest)
return
}
Log.d(TAG, "Displaying donation error dialog.", true)
errorDialog = DonationErrorDialogs.show(
fragment!!.requireContext(),
throwable,
object : DonationErrorDialogs.DialogCallback() {
var tryCCAgain = false
var tryAgain = false
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryCCAgain = true
tryAgain = true
}
)
}
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
return DonationErrorParams.ErrorAction(
label = R.string.DeclineCode__try,
action = {
tryAgain = true
}
)
}
override fun onDialogDismissed() {
errorDialog = null
if (!tryCCAgain) {
if (!tryAgain) {
tryAgain = false
fragment!!.findNavController().popBackStack()
}
}
@@ -289,15 +329,17 @@ class DonationCheckoutDelegate(
}
}
interface UserCancelledFlowCallback {
interface ErrorHandlerCallback {
fun onUserCancelledPaymentFlow()
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
}
interface Callback : UserCancelledFlowCallback {
interface Callback : ErrorHandlerCallback {
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest)
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
fun onPaymentComplete(gatewayRequest: GatewayRequest)
fun onProcessorActionProcessed()
}

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

@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c
import android.content.Context
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
@@ -18,6 +17,7 @@ import androidx.navigation.navGraphViewModels
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
@@ -29,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.st
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -48,9 +47,11 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
@@ -64,13 +65,13 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
binding.title.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
binding.continueButton.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.CreditCardFragment__donation_amount_s_per_month,
R.string.CreditCardFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat))
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.request.fiat))
}
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
@@ -140,13 +141,6 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
override fun onStart() {
super.onStart()
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
override fun onResume() {
super.onResume()
@@ -158,13 +152,6 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
}
}
override fun onStop() {
super.onStop()
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
private fun presentContinue(state: CreditCardValidationState) {
binding.continueButton.isEnabled = state.isValid
}

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
import org.thoughtcrime.securesms.recipients.Recipient
sealed interface GatewayOrderStrategy {
val orderedGateways: Set<GatewayResponse.Gateway>
private object Default : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
)
}
private object NorthAmerica : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.SEPA_DEBIT,
GatewayResponse.Gateway.IDEAL
)
}
private object Netherlands : GatewayOrderStrategy {
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
GatewayResponse.Gateway.IDEAL,
GatewayResponse.Gateway.PAYPAL,
GatewayResponse.Gateway.GOOGLE_PAY,
GatewayResponse.Gateway.CREDIT_CARD,
GatewayResponse.Gateway.SEPA_DEBIT
)
}
companion object {
fun getStrategy(): GatewayOrderStrategy {
val self = Recipient.self()
val e164 = self.e164.orNull() ?: return Default
return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) {
NorthAmerica
} else if (InAppDonations.isIDEALAvailable()) {
Netherlands
} else {
Default
}
}
}
}

View File

@@ -10,7 +10,8 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
GOOGLE_PAY,
PAYPAL,
CREDIT_CARD,
SEPA_DEBIT;
SEPA_DEBIT,
IDEAL;
fun toPaymentSourceType(): PaymentSourceType {
return when (this) {
@@ -18,6 +19,7 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
PAYPAL -> PaymentSourceType.PayPal
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
IDEAL -> PaymentSourceType.Stripe.IDEAL
}
}
}

View File

@@ -73,66 +73,113 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
return@configure
}
if (state.isGooglePayAvailable) {
customPref(
GooglePayButton.Model(
isEnabled = true,
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
)
}
if (state.isPayPalAvailable) {
space(8.dp)
customPref(
PayPalButton.Model(
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
},
isEnabled = true
)
)
}
if (state.isCreditCardAvailable) {
space(8.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
if (state.isSEPADebitAvailable) {
space(8.dp)
primaryButton(
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
val isFirst = index == 0
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)
}
}
space(16.dp)
}
}
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState, isFirstButton: Boolean) {
if (state.isGooglePayAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
GooglePayButton.Model(
isEnabled = true,
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
)
}
}
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState, isFirstButton: Boolean) {
if (state.isPayPalAvailable) {
if (!isFirstButton) {
space(8.dp)
}
customPref(
PayPalButton.Model(
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
},
isEnabled = true
)
)
}
}
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState, isFirstButton: Boolean) {
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),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
}
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState, isFirstButton: Boolean) {
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))
}
)
}
}
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
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),
onClick = {
findNavController().popBackStack()
val response = GatewayResponse(GatewayResponse.Gateway.IDEAL, args.request)
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
}
)
}
}
companion object {
const val REQUEST_KEY = "payment_checkout_mode"

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import io.reactivex.rxjava3.core.Single
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
import java.util.Locale
class GatewaySelectorRepository(
@@ -15,9 +16,10 @@ class GatewaySelectorRepository(
.map { configuration ->
configuration.getAvailablePaymentMethods(currencyCode).map {
when (it) {
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
DonationsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
DonationsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
DonationsConfiguration.SEPA_DEBIT -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
DonationsConfiguration.IDEAL -> listOf(GatewayResponse.Gateway.IDEAL)
else -> listOf()
}
}.flatten().toSet()

View File

@@ -3,10 +3,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
import org.thoughtcrime.securesms.badges.models.Badge
data class GatewaySelectorState(
val gatewayOrderStrategy: GatewayOrderStrategy,
val loading: Boolean = true,
val badge: Badge,
val isGooglePayAvailable: Boolean = false,
val isPayPalAvailable: Boolean = false,
val isCreditCardAvailable: Boolean = false,
val isSEPADebitAvailable: Boolean = false
val isSEPADebitAvailable: Boolean = false,
val isIDEALAvailable: Boolean = false
)

View File

@@ -21,11 +21,13 @@ class GatewaySelectorViewModel(
private val store = RxStore(
GatewaySelectorState(
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
badge = args.request.badge,
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType),
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.request.donateToSignalType)
)
)
private val disposables = CompositeDisposable()
@@ -43,7 +45,8 @@ class GatewaySelectorViewModel(
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)
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT),
isIDEALAvailable = it.isIDEALAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.IDEAL)
)
}
}

View File

@@ -67,6 +67,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
}
else -> error("Unsupported action: ${args.action}")
}
}
@@ -121,7 +122,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
return Single.create<PayPalConfirmationResult> { emitter ->
return Single.create { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: PayPalConfirmationResult? = bundle.getParcelableCompat(PayPalConfirmationDialogFragment.REQUEST_KEY, PayPalConfirmationResult::class.java)
if (result != null) {
@@ -149,7 +150,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
private fun routeToMonthlyConfirmation(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single<PayPalPaymentMethodId> {
return Single.create<PayPalPaymentMethodId> { emitter ->
return Single.create { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: Boolean = bundle.getBoolean(PayPalConfirmationDialogFragment.REQUEST_KEY)
if (result) {
@@ -175,31 +176,4 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
}
private fun <T : Any> displayCompleteOrderSheet(confirmationData: T): Single<T> {
return Single.create<T> { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: Boolean = bundle.getBoolean(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
if (result) {
Log.d(TAG, "User confirmed order. Continuing...")
emitter.onSuccess(confirmationData)
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
}
}
parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
parentFragmentManager.setFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY, this, listener)
findNavController().safeNavigate(
PayPalPaymentInProgressFragmentDirections.actionPaypalPaymentInProgressFragmentToPaypalCompleteOrderBottomSheet(args.request)
)
emitter.setCancellable {
Log.d(TAG, "Clearing complete order result listener.")
parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
parentFragmentManager.clearFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
}
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
}
}

View File

@@ -83,7 +83,7 @@ class PayPalPaymentInProgressViewModel(
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -93,8 +93,8 @@ class PayPalPaymentInProgressViewModel(
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
@@ -153,13 +153,10 @@ class PayPalPaymentInProgressViewModel(
}
.flatMapCompletable { response ->
oneTimeDonationRepository.waitForOneTimeRedemption(
price = request.fiat,
gatewayRequest = request,
paymentIntentId = response.paymentId,
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.PAYPAL,
uiSessionKey = request.uiSessionKey
paymentSourceType = PaymentSourceType.PayPal
)
}
.subscribeOn(Schedulers.io())
@@ -190,9 +187,9 @@ class PayPalPaymentInProgressViewModel(
.andThen(payPalRepository.createPaymentMethod())
.flatMap(routeToPaypalConfirmation)
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) }
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
.subscribeBy(
onError = { throwable ->
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
@@ -200,8 +197,8 @@ class PayPalPaymentInProgressViewModel(
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.R
/**
* Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback
* or play store parameters to launch them into the market.
*/
object ExternalNavigationHelper {
fun maybeLaunchExternalNavigationIntent(context: Context, webRequestUri: Uri?, launchIntent: (Intent) -> Unit): Boolean {
val url = webRequestUri ?: return false
if (url.scheme?.startsWith("http") == true || url.scheme == StripeApi.RETURN_URL_SCHEME) {
return false
}
MaterialAlertDialogBuilder(context)
.setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment)
.setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) }
.setNegativeButton(android.R.string.cancel, null)
.show()
return true
}
private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) {
val intent = Intent(Intent.ACTION_VIEW, url)
try {
launchIntent(intent)
} catch (e: ActivityNotFoundException) {
// Parses intent:// schema uris according to https://developer.chrome.com/docs/multidevice/android/intents/
if (url.scheme?.equals("intent") == true) {
val fragmentParts: Map<String, String?> = url.fragment
?.split(";")
?.associate {
val parts = it.split('=', limit = 2)
if (parts.size > 1) {
parts[0] to parts[1]
} else {
parts[0] to null
}
} ?: emptyMap()
val fallbackUri = fragmentParts["S.browser_fallback_url"]?.let { Uri.parse(it) }
val packageId: String? = if (looksLikeAMarketLink(fallbackUri)) {
fallbackUri!!.getQueryParameter("id")
} else {
fragmentParts["package"]
}
if (!packageId.isNullOrBlank()) {
try {
launchIntent(
Intent(
Intent.ACTION_VIEW,
Uri.parse("market://details?id=$packageId")
)
)
} catch (e: ActivityNotFoundException) {
toastOnActivityNotFound(context)
}
} else if (fallbackUri != null) {
try {
launchIntent(
Intent(
Intent.ACTION_VIEW,
fallbackUri
)
)
} catch (e: ActivityNotFoundException) {
toastOnActivityNotFound(context)
}
}
}
}
}
private fun toastOnActivityNotFound(context: Context) {
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show()
}
private fun looksLikeAMarketLink(uri: Uri?): Boolean {
return uri != null && uri.host == "play.google.com" && uri.getQueryParameter("id") != null
}
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.donations.PaymentSourceType
import org.signal.donations.StripeIntentAccessor
import org.thoughtcrime.securesms.badges.Badges
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toBigDecimal
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
import org.thoughtcrime.securesms.database.model.databaseprotos.ExternalLaunchTransactionState
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Encapsulates the data required to complete a pending external transaction
*/
@Parcelize
data class Stripe3DSData(
val stripeIntentAccessor: StripeIntentAccessor,
val gatewayRequest: GatewayRequest,
private val rawPaymentSourceType: String
) : Parcelable {
@IgnoredOnParcel
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
fun toProtoBytes(): ByteArray {
return ExternalLaunchTransactionState(
stripeIntentAccessor = ExternalLaunchTransactionState.StripeIntentAccessor(
type = when (stripeIntentAccessor.objectType) {
StripeIntentAccessor.ObjectType.NONE, StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT
StripeIntentAccessor.ObjectType.SETUP_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT
},
intentId = stripeIntentAccessor.intentId,
intentClientSecret = stripeIntentAccessor.intentClientSecret
),
gatewayRequest = ExternalLaunchTransactionState.GatewayRequest(
donateToSignalType = when (gatewayRequest.donateToSignalType) {
DonateToSignalType.ONE_TIME -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
DonateToSignalType.MONTHLY -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
DonateToSignalType.GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
},
badge = Badges.toDatabaseBadge(gatewayRequest.badge),
label = gatewayRequest.label,
price = gatewayRequest.price.toDecimalValue(),
currencyCode = gatewayRequest.currencyCode,
level = gatewayRequest.level,
recipient_id = gatewayRequest.recipientId.toLong(),
additionalMessage = gatewayRequest.additionalMessage ?: ""
),
paymentSourceType = paymentSourceType.code
).encode()
}
companion object {
fun fromProtoBytes(byteArray: ByteArray, uiSessionKey: Long): Stripe3DSData {
val proto = ExternalLaunchTransactionState.ADAPTER.decode(byteArray)
return Stripe3DSData(
stripeIntentAccessor = StripeIntentAccessor(
objectType = when (proto.stripeIntentAccessor!!.type) {
ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT -> StripeIntentAccessor.ObjectType.PAYMENT_INTENT
ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT -> StripeIntentAccessor.ObjectType.SETUP_INTENT
},
intentId = proto.stripeIntentAccessor.intentId,
intentClientSecret = proto.stripeIntentAccessor.intentClientSecret
),
gatewayRequest = GatewayRequest(
uiSessionKey = uiSessionKey,
donateToSignalType = when (proto.gatewayRequest!!.donateToSignalType) {
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> DonateToSignalType.MONTHLY
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> DonateToSignalType.ONE_TIME
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> DonateToSignalType.GIFT
},
badge = Badges.fromDatabaseBadge(proto.gatewayRequest.badge!!),
label = proto.gatewayRequest.label,
price = proto.gatewayRequest.price!!.toBigDecimal(),
currencyCode = proto.gatewayRequest.currencyCode,
level = proto.gatewayRequest.level,
recipientId = RecipientId.from(proto.gatewayRequest.recipient_id),
additionalMessage = proto.gatewayRequest.additionalMessage.takeIf { it.isNotBlank() }
),
rawPaymentSourceType = proto.paymentSourceType
)
}
}
}

View File

@@ -2,9 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
@@ -18,6 +21,7 @@ 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.visible
/**
@@ -27,6 +31,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
companion object {
const val REQUEST_KEY = "stripe_3ds_dialog_fragment"
const val LAUNCHED_EXTERNAL = "stripe_3ds_dialog_fragment.pending"
}
val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
@@ -45,8 +50,14 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
dialog!!.window!!.setFlags(
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
)
binding.webView.webViewClient = Stripe3DSWebClient()
binding.webView.settings.javaScriptEnabled = true
binding.webView.settings.domStorageEnabled = true
binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
binding.webView.loadUrl(args.uri.toString())
@@ -66,8 +77,24 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
setFragmentResult(REQUEST_KEY, result ?: Bundle())
}
private fun handleLaunchExternal(intent: Intent) {
startActivity(intent)
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
result = bundleOf(
LAUNCHED_EXTERNAL to true
)
dismissAllowingStateLoss()
}
private inner class Stripe3DSWebClient : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
return ExternalNavigationHelper.maybeLaunchExternalNavigationIntent(requireContext(), request?.url, this@Stripe3DSDialogFragment::handleLaunchExternal)
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
binding.progress.visible = true
}

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
import io.reactivex.rxjava3.core.Single
import org.signal.donations.StripeApi
import org.signal.donations.StripeIntentAccessor
fun interface StripeNextActionHandler {
fun handle(
action: StripeApi.Secure3DSAction,
stripe3DSData: Stripe3DSData
): Single<StripeIntentAccessor>
}

View File

@@ -66,7 +66,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
}
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
viewModel.updateSubscription(args.request)
viewModel.updateSubscription(args.request, args.isLongRunning)
}
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
viewModel.cancelSubscription()
@@ -116,7 +116,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
}
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single<StripeIntentAccessor> {
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single<StripeIntentAccessor> {
return when (secure3dsAction) {
is StripeApi.Secure3DSAction.NotNeeded -> {
Log.d(TAG, "No 3DS action required.")
@@ -124,19 +124,24 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
}
is StripeApi.Secure3DSAction.ConfirmRequired -> {
Log.d(TAG, "3DS action required. Displaying dialog...")
Single.create<StripeIntentAccessor> { emitter ->
Single.create { emitter ->
val listener = FragmentResultListener { _, bundle ->
val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java)
if (result != null) {
emitter.onSuccess(result)
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
if (didLaunchExternal) {
emitter.onError(DonationError.UserLaunchedExternalApplication(args.request.donateToSignalType.toErrorSource()))
} else {
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
}
}
}
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri))
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, stripe3DSData))
emitter.setCancellable {
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)

View File

@@ -69,12 +69,12 @@ class StripePaymentInProgressViewModel(
disposables.clear()
}
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
fun processNewDonation(request: GatewayRequest, nextActionHandler: StripeNextActionHandler) {
Log.d(TAG, "Proceeding with donation...", true)
val errorSource = when (request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
@@ -93,14 +93,22 @@ class StripePaymentInProgressViewModel(
PaymentSourceType.Stripe.GooglePay,
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.CreditCard -> PaymentSourceProvider(
PaymentSourceType.Stripe.CreditCard,
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
PaymentSourceType.Stripe.SEPADebit,
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
)
is StripePaymentData.IDEAL -> PaymentSourceProvider(
PaymentSourceType.Stripe.IDEAL,
stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() }
)
else -> error("This should never happen.")
}
}
@@ -120,6 +128,11 @@ class StripePaymentInProgressViewModel(
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
}
fun provideIDEALData(bankData: StripeApi.IDEALData) {
requireNoPaymentInformation()
this.stripePaymentData = StripePaymentData.IDEAL(bankData)
}
private fun requireNoPaymentInformation() {
require(stripePaymentData == null)
}
@@ -129,13 +142,13 @@ class StripePaymentInProgressViewModel(
stripePaymentData = null
}
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
}
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isBankTransfer)
Log.d(TAG, "Starting subscription payment pipeline...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
@@ -144,16 +157,22 @@ class StripePaymentInProgressViewModel(
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
.andThen(createAndConfirmSetupIntent)
.flatMap { secure3DSAction ->
nextActionHandler(secure3DSAction)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
nextActionHandler.handle(
action = secure3DSAction,
Stripe3DSData(
secure3DSAction.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
)
)
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
}
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) }
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it.paymentMethod!!, it.intentId, paymentSourceProvider.paymentSourceType) }
.onErrorResumeNext {
when {
it is DonationError -> Completable.error(it)
it is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.SUBSCRIPTION, paymentSourceProvider.paymentSourceType))
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
when (it) {
is DonationError -> Completable.error(it)
is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType))
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, paymentSourceProvider.paymentSourceType))
}
}
@@ -165,7 +184,7 @@ class StripePaymentInProgressViewModel(
val donationError: DonationError = if (throwable is DonationError) {
throwable
} else {
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
},
@@ -179,7 +198,7 @@ class StripePaymentInProgressViewModel(
private fun proceedOneTime(
request: GatewayRequest,
paymentSourceProvider: PaymentSourceProvider,
nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>
nextActionHandler: StripeNextActionHandler
) {
Log.w(TAG, "Beginning one-time payment pipeline...", true)
@@ -195,17 +214,24 @@ class StripePaymentInProgressViewModel(
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
.flatMap { nextActionHandler(it) }
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
.flatMap { action ->
nextActionHandler
.handle(
action,
Stripe3DSData(
action.stripeIntentAccessor,
request,
paymentSourceProvider.paymentSourceType.code
)
)
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
}
.flatMapCompletable {
oneTimeDonationRepository.waitForOneTimeRedemption(
price = amount,
gatewayRequest = request,
paymentIntentId = paymentIntent.intentId,
badgeRecipient = request.recipientId,
additionalMessage = request.additionalMessage,
badgeLevel = request.level,
donationProcessor = DonationProcessor.STRIPE,
uiSessionKey = request.uiSessionKey
paymentSourceType = paymentSource.type
)
}
}.subscribeBy(
@@ -246,11 +272,10 @@ class StripePaymentInProgressViewModel(
)
}
fun updateSubscription(request: GatewayRequest) {
fun updateSubscription(request: GatewayRequest, isLongRunning: Boolean) {
Log.d(TAG, "Beginning subscription update...", true)
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, isLongRunning))
.subscribeBy(
onComplete = {
Log.w(TAG, "Completed subscription update", true)
@@ -260,8 +285,8 @@ class StripePaymentInProgressViewModel(
Log.w(TAG, "Failed to update subscription", throwable, true)
val donationError: DonationError = when (throwable) {
is DonationError -> throwable
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.Stripe.GooglePay)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.Stripe.GooglePay)
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
}
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
@@ -279,6 +304,7 @@ class StripePaymentInProgressViewModel(
class GooglePay(val paymentData: PaymentData) : StripePaymentData
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
class IDEAL(val idealData: StripeApi.IDEALData) : StripePaymentData
}
class Factory(

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
object BankTransferRequestKeys {
const val REQUEST_KEY = "bank.transfer.result"
const val PENDING_KEY = "bank.transfer.pending"
}

View File

@@ -5,6 +5,8 @@
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
@@ -29,7 +31,6 @@ import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -38,6 +39,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
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.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
@@ -46,14 +50,21 @@ import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
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.errors.DonationErrorSource
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -61,7 +72,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
*/
class BankTransferDetailsFragment : ComposeFragment() {
class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
private val args: BankTransferDetailsFragmentArgs by navArgs()
private val viewModel: BankTransferDetailsViewModel by viewModels()
@@ -73,6 +84,26 @@ class BankTransferDetailsFragment : ComposeFragment() {
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
}
}
}
@Composable
override fun FragmentContent() {
val state: BankTransferDetailsState by viewModel.state
@@ -97,7 +128,8 @@ class BankTransferDetailsFragment : ComposeFragment() {
onNameChanged = viewModel::onNameChanged,
onIBANChanged = viewModel::onIBANChanged,
onEmailChanged = viewModel::onEmailChanged,
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
setDisplayFindAccountInfoSheet = viewModel::setDisplayFindAccountInfoSheet,
onLearnMoreClick = this::onLearnMoreClick,
onDonateClick = this::onDonateClick,
onIBANFocusChanged = viewModel::onIBANFocusChanged,
donateLabel = donateLabel
@@ -108,8 +140,10 @@ class BankTransferDetailsFragment : ComposeFragment() {
findNavController().popBackStack()
}
private fun onFindAccountNumbersClicked() {
// TODO [sepa] -- FindAccountNumbersBottomSheet
private fun onLearnMoreClick() {
findNavController().safeNavigate(
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()
)
}
private fun onDonateClick() {
@@ -121,6 +155,15 @@ class BankTransferDetailsFragment : ComposeFragment() {
)
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
}
}
@Preview
@@ -129,13 +172,15 @@ private fun BankTransferDetailsContentPreview() {
SignalTheme {
BankTransferDetailsContent(
state = BankTransferDetailsState(
name = "Miles Morales"
name = "Miles Morales",
displayFindAccountInfoSheet = true
),
onNavigationClick = {},
onNameChanged = {},
onIBANChanged = {},
onEmailChanged = {},
onFindAccountNumbersClicked = {},
setDisplayFindAccountInfoSheet = {},
onLearnMoreClick = {},
onDonateClick = {},
onIBANFocusChanged = {},
donateLabel = "Donate $5/month"
@@ -150,7 +195,8 @@ private fun BankTransferDetailsContent(
onNameChanged: (String) -> Unit,
onIBANChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onFindAccountNumbersClicked: () -> Unit,
setDisplayFindAccountInfoSheet: (Boolean) -> Unit,
onLearnMoreClick: () -> Unit,
onDonateClick: () -> Unit,
onIBANFocusChanged: (Boolean) -> Unit,
donateLabel: String
@@ -178,14 +224,13 @@ private fun BankTransferDetailsContent(
item {
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyLarge.copy(
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
@@ -211,11 +256,11 @@ private fun BankTransferDetailsContent(
if (state.ibanValidity.isError) {
Text(
text = when (state.ibanValidity) {
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_is_too_short)
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_is_too_long)
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban)
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban)
else -> error("Unexpected error.")
}
)
@@ -276,9 +321,9 @@ private fun BankTransferDetailsContent(
modifier = Modifier.fillMaxWidth()
) {
TextButton(
onClick = onFindAccountNumbersClicked
onClick = { setDisplayFindAccountInfoSheet(true) }
) {
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_info))
}
}
}
@@ -294,6 +339,10 @@ private fun BankTransferDetailsContent(
Text(text = donateLabel)
}
if (state.displayFindAccountInfoSheet) {
FindAccountInfoSheet { setDisplayFindAccountInfoSheet(false) }
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}

View File

@@ -11,15 +11,16 @@ data class BankTransferDetailsState(
val name: String = "",
val iban: String = "",
val email: String = "",
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID,
val displayFindAccountInfoSheet: Boolean = false
) {
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
val canProceed = name.isNotBlank() && email.isNotBlank() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
fun asSEPADebitData(): StripeApi.SEPADebitData {
return StripeApi.SEPADebitData(
iban = iban,
name = name,
email = email
iban = iban.trim(),
name = name.trim(),
email = email.trim()
)
}
}

View File

@@ -18,6 +18,12 @@ class BankTransferDetailsViewModel : ViewModel() {
private val internalState = mutableStateOf(BankTransferDetailsState())
val state: State<BankTransferDetailsState> = internalState
fun setDisplayFindAccountInfoSheet(displayFindAccountInfoSheet: Boolean) {
internalState.value = internalState.value.copy(
displayFindAccountInfoSheet = displayFindAccountInfoSheet
)
}
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.BottomSheets
import org.thoughtcrime.securesms.R
/**
* Displays a modal bottom sheet that explains where to find the information necessary to perform
* a bank transfer.
*/
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun FindAccountInfoSheet(
onDismissRequest: () -> Unit
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
dragHandle = { BottomSheets.Handle() }
) {
Image(
painter = painterResource(id = R.drawable.find_account_info),
contentDescription = null,
modifier = Modifier
.align(CenterHorizontally)
.padding(vertical = 32.dp)
)
Text(
text = stringResource(id = R.string.FindAccountInfoSheet__find_your_account_information),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 60.dp)
.align(CenterHorizontally)
)
Text(
text = stringResource(id = R.string.FindAccountInfoSheet__look_for_your_iban_at),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 48.dp, start = 60.dp, end = 60.dp)
.align(CenterHorizontally)
)
}
}

View File

@@ -95,17 +95,19 @@ object IBANValidator {
}
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
if (iban.isEmpty()) {
val trimmedIban = iban.trim()
if (trimmedIban.isEmpty()) {
return Validity.POTENTIALLY_VALID
}
val lengthValidity = validateLength(iban, isIBANFieldFocused)
val lengthValidity = validateLength(trimmedIban, isIBANFieldFocused)
if (lengthValidity != Validity.COMPLETELY_VALID) {
return lengthValidity
}
val countryAndCheck = iban.take(4)
val rearranged = iban.drop(4) + countryAndCheck
val countryAndCheck = trimmedIban.take(4)
val rearranged = trimmedIban.drop(4) + countryAndCheck
val expanded = rearranged.map {
if (it.isLetter()) {
(it - 'A') + 10

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import org.thoughtcrime.securesms.R
import java.util.EnumMap
/**
* Set of banks that are supported for iDEAL transfers, as listed here:
* https://stripe.com/docs/api/payment_methods/object#payment_method_object-ideal-bank
*/
enum class IdealBank(
val code: String
) {
ABN_AMRO("abn_amro"),
ASN_BANK("asn_bank"),
BUNQ("bunq"),
ING("ing"),
KNAB("knab"),
N26("n26"),
RABOBANK("rabobank"),
REGIOBANK("regiobank"),
REVOLUT("revolut"),
SNS_BANK("sns_bank"),
TRIODOS_BANK("triodos_bank"),
VAN_LANCHOT("van_lanchot"),
YOURSAFE("yoursafe");
fun getUIValues(): UIValues = bankToUIValues[this]!!
companion object {
private val bankToUIValues: Map<IdealBank, UIValues> by lazy {
EnumMap<IdealBank, UIValues>(IdealBank::class.java).apply {
putAll(
arrayOf(
ABN_AMRO to UIValues(
name = R.string.IdealBank__abn_amro,
icon = R.drawable.ideal_abn_amro
),
ASN_BANK to UIValues(
name = R.string.IdealBank__asn_bank,
icon = R.drawable.ideal_asn
),
BUNQ to UIValues(
name = R.string.IdealBank__bunq,
icon = R.drawable.ideal_bunq
),
ING to UIValues(
name = R.string.IdealBank__ing,
icon = R.drawable.ideal_ing
),
KNAB to UIValues(
name = R.string.IdealBank__knab,
icon = R.drawable.ideal_knab
),
N26 to UIValues(
name = R.string.IdealBank__n26,
icon = R.drawable.ideal_n26
),
RABOBANK to UIValues(
name = R.string.IdealBank__rabobank,
icon = R.drawable.ideal_rabobank
),
REGIOBANK to UIValues(
name = R.string.IdealBank__regiobank,
icon = R.drawable.ideal_regiobank
),
REVOLUT to UIValues(
name = R.string.IdealBank__revolut,
icon = R.drawable.ideal_revolut
),
SNS_BANK to UIValues(
name = R.string.IdealBank__sns_bank,
icon = R.drawable.ideal_sns
),
TRIODOS_BANK to UIValues(
name = R.string.IdealBank__triodos_bank,
icon = R.drawable.ideal_triodos_bank
),
VAN_LANCHOT to UIValues(
name = R.string.IdealBank__van_lanchot,
icon = R.drawable.ideal_van_lanschot
),
YOURSAFE to UIValues(
name = R.string.IdealBank__yoursafe,
icon = R.drawable.ideal_yoursafe
)
)
)
}
}
fun fromCode(code: String): IdealBank {
return values().first { it.code == code }
}
}
data class UIValues(
@StringRes val name: Int,
@DrawableRes val icon: Int
)
}

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.Scaffolds
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
/**
* Dialog fragment for selecting the bank for the iDEAL donation.
*/
class IdealTransferDetailsBankSelectionDialogFragment : ComposeDialogFragment() {
companion object {
const val IDEAL_SELECTED_BANK = "ideal.selected.bank"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@Composable
override fun DialogContent() {
BankSelectionContent(
onNavigationClick = { findNavController().popBackStack() },
onBankSelected = {
dismissAllowingStateLoss()
setFragmentResult(
IDEAL_SELECTED_BANK,
bundleOf(
IDEAL_SELECTED_BANK to it.code
)
)
}
)
}
}
@Preview
@Composable
private fun BankSelectionContentPreview() {
BankSelectionContent(
onNavigationClick = {},
onBankSelected = {}
)
}
@Composable
private fun BankSelectionContent(
onNavigationClick: () -> Unit,
onBankSelected: (IdealBank) -> Unit
) {
Scaffolds.Settings(
title = stringResource(R.string.IdealTransferDetailsBankSelectionDialogFragment__choose_your_bank),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24)
) { paddingValues ->
LazyColumn(modifier = Modifier.padding(paddingValues)) {
items(IdealBank.values()) {
val uiValues = it.getUIValues()
Row(
verticalAlignment = CenterVertically,
modifier = Modifier
.clickable { onBankSelected(it) }
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter), vertical = 8.dp)
) {
Image(
painter = painterResource(id = uiValues.icon),
contentDescription = null,
modifier = Modifier
.size(40.dp)
)
Text(
text = stringResource(uiValues.name),
modifier = Modifier.padding(start = 24.dp)
)
}
}
}
}
}

View File

@@ -0,0 +1,338 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import android.os.Bundle
import android.view.View
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import org.signal.core.ui.Buttons
import org.signal.core.ui.Scaffolds
import org.signal.core.ui.Texts
import org.signal.core.util.getParcelableCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
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.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
/**
* Fragment for inputting necessary bank transfer information for iDEAL donation
*/
class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
private val args: IdealTransferDetailsFragmentArgs by navArgs()
private val viewModel: IdealTransferDetailsViewModel by viewModels()
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
R.id.donate_to_signal,
factoryProducer = {
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
}
)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
}
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
}
}
setFragmentResultListener(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK) { _, bundle ->
val bankCode = bundle.getString(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK)!!
viewModel.onBankSelected(IdealBank.fromCode(bankCode))
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state
val donateLabel = remember(args.request) {
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
getString(
R.string.BankTransferDetailsFragment__donate_s_month,
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
)
} else {
getString(
R.string.BankTransferDetailsFragment__donate_s,
FiatMoneyUtil.format(resources, args.request.fiat)
)
}
}
IdealTransferDetailsContent(
state = state,
donateLabel = donateLabel,
onNavigationClick = { findNavController().popBackStack() },
onLearnMoreClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()) },
onSelectBankClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionIdealTransferDetailsFragmentToIdealTransferBankSelectionDialogFragment()) },
onNameChanged = viewModel::onNameChanged,
onEmailChanged = viewModel::onEmailChanged,
onDonateClick = this::onDonateClick
)
}
private fun onDonateClick() {
stripePaymentViewModel.provideIDEALData(viewModel.state.value.asIDEALData())
findNavController().safeNavigate(
IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
DonationProcessorAction.PROCESS_NEW_DONATION,
args.request
)
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
}
}
@Preview
@Composable
private fun IdealTransferDetailsContentPreview() {
IdealTransferDetailsContent(
state = IdealTransferDetailsState(),
donateLabel = "Donate $5/month",
onNavigationClick = {},
onLearnMoreClick = {},
onSelectBankClick = {},
onNameChanged = {},
onEmailChanged = {},
onDonateClick = {}
)
}
@Composable
private fun IdealTransferDetailsContent(
state: IdealTransferDetailsState,
donateLabel: String,
onNavigationClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onSelectBankClick: () -> Unit,
onNameChanged: (String) -> Unit,
onEmailChanged: (String) -> Unit,
onDonateClick: () -> Unit
) {
Scaffolds.Settings(
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
onNavigationClick = onNavigationClick,
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
) {
val focusManager = LocalFocusManager.current
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier.padding(it)
) {
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(horizontal = 24.dp)
) {
item {
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
onUrlClick = {
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(vertical = 12.dp)
)
}
item {
IdealBankSelector(
idealBank = state.idealBank,
onSelectBankClick = onSelectBankClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
item {
TextField(
value = state.name,
onValueChange = onNameChanged,
label = {
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__name_on_bank_account))
},
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
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)
)
}
}
Buttons.LargeTonal(
enabled = state.canProceed(),
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
) {
Text(text = donateLabel)
}
}
}
}
@Preview
@Composable
private fun IdealBankSelectorPreview() {
IdealBankSelector(
idealBank = null,
onSelectBankClick = {}
)
}
@Composable
private fun IdealBankSelector(
idealBank: IdealBank?,
onSelectBankClick: () -> Unit,
modifier: Modifier = Modifier
) {
val uiValues: IdealBank.UIValues? = remember(idealBank) { idealBank?.getUIValues() }
val imagePadding: Dp = if (idealBank == null) 4.dp else 0.dp
TextField(
value = stringResource(id = uiValues?.name ?: R.string.IdealTransferDetailsFragment__choose_your_bank),
textStyle = MaterialTheme.typography.bodyLarge,
onValueChange = {},
enabled = false,
readOnly = true,
leadingIcon = {
Image(
painter = painterResource(id = uiValues?.icon ?: R.drawable.bank_transfer),
contentDescription = null,
modifier = Modifier
.padding(start = 16.dp, end = 12.dp)
.size(32.dp)
.padding(imagePadding)
)
},
trailingIcon = {
Icon(
painter = painterResource(id = R.drawable.symbol_dropdown_triangle_compat_bold_16),
contentDescription = null
)
},
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
),
modifier = modifier
.clickable(
onClick = onSelectBankClick,
role = Role.Button
)
)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import org.signal.donations.StripeApi
data class IdealTransferDetailsState(
val idealBank: IdealBank? = null,
val name: String = "",
val email: String = ""
) {
fun asIDEALData(): StripeApi.IDEALData {
return StripeApi.IDEALData(
bank = idealBank!!.code,
name = name.trim(),
email = email.trim()
)
}
fun canProceed(): Boolean {
return idealBank != null && name.isNotBlank() && email.isNotBlank()
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class IdealTransferDetailsViewModel : ViewModel() {
private val internalState = mutableStateOf(IdealTransferDetailsState())
var state: State<IdealTransferDetailsState> = internalState
fun onNameChanged(name: String) {
internalState.value = internalState.value.copy(
name = name
)
}
fun onEmailChanged(email: String) {
internalState.value = internalState.value.copy(
email = email
)
}
fun onBankSelected(idealBank: IdealBank) {
internalState.value = internalState.value.copy(
idealBank = idealBank
)
}
}

View File

@@ -5,44 +5,63 @@
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
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.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels
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.util.CommunicationActions
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
/**
* Displays Bank Transfer legal mandate users must agree to to move forward.
@@ -50,16 +69,39 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
class BankTransferMandateFragment : ComposeFragment() {
private val args: BankTransferMandateFragmentArgs by navArgs()
private val viewModel: BankTransferMandateViewModel by viewModels()
private val viewModel: BankTransferMandateViewModel by viewModel {
BankTransferMandateViewModel(PaymentSourceType.Stripe.SEPADebit)
}
private lateinit var statusBarColorAnimator: StatusBarColorAnimator
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
statusBarColorAnimator = StatusBarColorAnimator(requireActivity())
}
override fun onResume() {
super.onResume()
statusBarColorAnimator.setColorImmediate()
}
@Composable
override fun FragmentContent() {
val mandate by viewModel.mandate
val failedToLoadMandate by viewModel.failedToLoadMandate
BankTransferScreen(
bankMandate = mandate,
failedToLoadMandate = failedToLoadMandate,
onNavigationClick = this::onNavigationClick,
onContinueClick = this::onContinueClick
onContinueClick = this::onContinueClick,
onLearnMoreClick = this::onLearnMoreClick,
onCanScrollUp = statusBarColorAnimator::setCanScrollUp
)
}
private fun onLearnMoreClick() {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToYourInformationIsPrivateBottomSheet()
)
}
@@ -68,9 +110,15 @@ class BankTransferMandateFragment : ComposeFragment() {
}
private fun onContinueClick() {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
)
if (args.response.gateway == GatewayResponse.Gateway.SEPA_DEBIT) {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.response.request)
)
} else {
findNavController().safeNavigate(
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.response.request)
)
}
}
}
@@ -80,39 +128,70 @@ fun BankTransferScreenPreview() {
SignalTheme {
BankTransferScreen(
bankMandate = "Test ".repeat(500),
failedToLoadMandate = false,
onNavigationClick = {},
onContinueClick = {}
onContinueClick = {},
onLearnMoreClick = {},
onCanScrollUp = {}
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun BankTransferScreen(
bankMandate: String,
failedToLoadMandate: Boolean,
onNavigationClick: () -> Unit,
onContinueClick: () -> Unit
onContinueClick: () -> Unit,
onLearnMoreClick: () -> Unit,
onCanScrollUp: (Boolean) -> Unit
) {
Scaffolds.Settings(
title = "",
onNavigationClick = onNavigationClick,
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
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()
)
}
) {
Column(
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
onCanScrollUp(listState.canScrollBackward)
Column(horizontalAlignment = CenterHorizontally, modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
horizontalAlignment = CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.weight(1f, true)
.padding(top = 64.dp)
) {
item {
Image(
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
painter = painterResource(id = R.drawable.bank_transfer),
contentScale = ContentScale.Inside,
contentDescription = null,
modifier = Modifier
@@ -135,17 +214,18 @@ fun BankTransferScreen(
item {
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
val context = LocalContext.current
Texts.LinkifiedText(
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
onUrlClick = {
CommunicationActions.openBrowserLink(context, it)
onLearnMoreClick()
},
style = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
modifier = Modifier
.padding(bottom = 12.dp)
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
)
}
@@ -155,20 +235,36 @@ fun BankTransferScreen(
item {
Text(
text = bankMandate,
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
)
}
}
Buttons.LargeTonal(
onClick = onContinueClick,
modifier = Modifier
.padding(top = 16.dp, bottom = 46.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
if (!failedToLoadMandate) {
Surface(
shadowElevation = if (listState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Buttons.LargeTonal(
onClick = {
if (!listState.canScrollForward) {
onContinueClick()
} else {
scope.launch {
listState.animateScrollBy(value = 1000f)
}
}
},
modifier = Modifier
.wrapContentWidth()
.padding(top = 16.dp, bottom = 16.dp)
.defaultMinSize(minWidth = 220.dp)
) {
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__continue))
}
}
}
}
}

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