Compare commits

..

164 Commits

Author SHA1 Message Date
Cody Henthorne
1222c30738 Bump version to 6.41.3 2023-12-04 16:12:20 -05:00
Cody Henthorne
0c8e62add9 Update translations and other static files. 2023-12-04 16:07:13 -05:00
Cody Henthorne
eb1d06b4a6 Fix thumbnail info generation bug in notifications. 2023-12-04 16:01:43 -05:00
Greyson Parrelli
d58c3292d7 Only use apk uploadTimestamp for non-website builds.
Relates to #13273
2023-12-04 15:54:14 -05:00
Greyson Parrelli
4320d26a3d Do not read PNP FF in job. 2023-12-04 15:12:20 -05:00
Cody Henthorne
3ca4e33d94 Fix sepa badge redemption job. 2023-12-04 15:12:20 -05:00
Greyson Parrelli
19e726a630 Bump version to 6.41.2 2023-11-17 15:10:15 -05:00
Greyson Parrelli
96dddef271 Update translations and other static files. 2023-11-17 15:09:35 -05:00
Cody Henthorne
34a228f85e Remove GV1 migration support. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
213d996168 Fix issues with some japanese numbers being detected as shortcodes. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
5a159ce01f Update libphonenumber to 8.13.23 2023-11-17 14:25:47 -05:00
Cody Henthorne
fed9c64113 Fix false-positive CVC errors in credit card donation flow. 2023-11-17 14:25:47 -05:00
Nicholas Tinsley
2d835581a5 Set audio picker bottom sheet text color to onSurface. 2023-11-17 14:25:47 -05:00
Nicholas Tinsley
c8f1ebdf4c Fix speakerphone drawables for selection. 2023-11-17 14:25:47 -05:00
Greyson Parrelli
98e3530acd Bump version to 6.41.1 2023-11-16 17:12:19 -05:00
Greyson Parrelli
1a5b216dd5 Update translations and other static files. 2023-11-16 17:11:47 -05:00
Cody Henthorne
ae98d5e3bd Fix NPE in wifi direct connection establishment. 2023-11-16 16:37:38 -05:00
Greyson Parrelli
750825b3c3 Fix potential bug with the in-app updater. 2023-11-16 16:19:50 -05:00
Cody Henthorne
8c255256c9 Remove mms_config xmls. 2023-11-16 16:19:50 -05:00
Cody Henthorne
19626361ec Fix bug allowing creation of new and sending in existing MMS groups. 2023-11-16 16:19:50 -05:00
Cody Henthorne
df4bd1fa4a Replace monthly badge expires with cancellation dialogs. 2023-11-16 10:22:01 -05:00
Greyson Parrelli
62bf5abd8d Bump version to 6.41.0 2023-11-15 17:32:01 -05:00
Greyson Parrelli
cd9ec9f346 Update translations and other static files. 2023-11-15 17:30:04 -05:00
Greyson Parrelli
cf7d5b3481 Remove deprecated storage service fields. 2023-11-15 17:02:44 -05:00
Cody Henthorne
12f9ac3aa4 Use shorter string for tab for better localization. 2023-11-15 17:02:44 -05:00
Greyson Parrelli
4519cdb49c Remove some unnecessary transactions in MessageContentProcessor. 2023-11-15 17:02:28 -05:00
Jim Gustafson
d20b6f355c Enable opus low bitrate redundancy for internal testing. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
70e64003f9 Unconditionally enable the PNI capability. 2023-11-15 17:02:21 -05:00
Nicholas Tinsley
0a4644e743 Update conversation shortcuts onPause. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
c428d23d8b Install prompt notification should dismiss failures and vice-versa. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
d6b189badc Fix potential binding crash. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
6e899391c0 Add back the foreign key transaction dance. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e0acbcc32d Perform one database upgrade at a time, saving progress as we go. 2023-11-15 17:02:21 -05:00
Cody Henthorne
95fb9ea117 Remove old remote configs. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e80b7cf0a2 Store receipt fields as booleans instead of counts. 2023-11-15 17:02:21 -05:00
Cody Henthorne
5e70c06075 Rotate ideal and sepa flags. 2023-11-15 17:02:21 -05:00
Cody Henthorne
1413b74f76 Add 'Add remote donate megaphone' to internal settings. 2023-11-15 17:02:21 -05:00
Cody Henthorne
bf0548e802 Fix donation-based remote config region checks. 2023-11-15 17:02:21 -05:00
Clark
b7e1863526 Fix timezone weirdness with scheduled messages. 2023-11-15 17:02:21 -05:00
Cody Henthorne
f189188563 Fix snackbar colors on older api verisons. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
2f52664820 Merge MediaMmsMessageRecord into MmsMessageRecord. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
5f6fa73be9 Delete NotificationMmsMessageRecord. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
b7ec913cb9 Improve receipt perf by caching the pending PNI signature table. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
ebef4b079c Fix LRUCache to be ordered by access time. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
a81e5c4e6b Improve receipt processing via faster thread updates. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
b0733dcd51 Reduce transactions during getAndPossiblyMerge. 2023-11-15 17:02:21 -05:00
Greyson Parrelli
e9bd35619d Add migration to fix registration state of some users. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
6528b34152 Fix username education layout when text is long. 2023-11-11 13:34:48 -05:00
Rashad Sookram
b60c02e0c7 Update to RingRTC v2.34.4 2023-11-11 13:34:48 -05:00
Greyson Parrelli
a0792d166b Add additional logging around apk updates. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
fcf36c4bc0 Fix color of x in color picker. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
e5b617cd16 Fix text color in username link sharing bottom sheet. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
0acefb4521 Fix storage sync issues with usernames. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
111c8367a9 Fix discoverability setting persistence during registration. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
ead8f209b6 Fix 'next' button alignment during registration. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
96333b616b Add username link share sheet. 2023-11-11 13:34:48 -05:00
Greyson Parrelli
5698e0deda Bump version to 6.40.4 2023-11-11 12:38:36 -05:00
Greyson Parrelli
df2ddebf6c Bump version to 6.40.3 2023-11-11 12:05:20 -05:00
Greyson Parrelli
71ab7528e7 Fix shared group membership check. 2023-11-11 12:04:55 -05:00
Cody Henthorne
b4e459d831 Bump version to 6.40.2 2023-11-10 15:44:59 -05:00
Cody Henthorne
a57d3fdf3f Updated baseline profile. 2023-11-10 15:39:20 -05:00
Cody Henthorne
2c207873be Update translations and other static files. 2023-11-10 15:34:43 -05:00
Cody Henthorne
fc8385113f Fix system ANR when loading avatars for system UI. 2023-11-10 15:27:57 -05:00
Cody Henthorne
95d7d26f11 Add SEPA max amount exceeded dialog. 2023-11-10 15:27:57 -05:00
AsamK
43a13964bd Fix leaking okhttp response in error case.
Closes #13246
2023-11-10 15:27:57 -05:00
Cody Henthorne
d2053d2db7 Bump version to 6.40.1 2023-11-09 16:52:42 -05:00
Cody Henthorne
8ba2bcaa53 Updated baseline profile. 2023-11-09 16:30:25 -05:00
Cody Henthorne
f7abdbe97f Update translations and other static files. 2023-11-09 16:27:36 -05:00
Greyson Parrelli
91af3e60ba Fix potential NPE when building an account record. 2023-11-09 16:13:46 -05:00
Clark
8fe196cd7a Don't renotify every single message on new message. 2023-11-09 12:29:59 -05:00
Cody Henthorne
66d7241c03 Update donation learn more urls in error states. 2023-11-09 12:06:27 -05:00
Cody Henthorne
89d7c0b0d0 Bump version to 6.40.0 2023-11-08 20:11:41 -05:00
Cody Henthorne
d2ec62d681 Updated baseline profile. 2023-11-08 20:02:59 -05:00
Cody Henthorne
b6d38fe8f1 Update translations and other static files. 2023-11-08 19:57:56 -05:00
Cody Henthorne
1edc256148 Rotate ideal and sepa flags. 2023-11-08 19:51:46 -05:00
Cody Henthorne
24ac385898 Fix dark theme issues with compose bottom sheets and donation bank name typo. 2023-11-08 19:51:46 -05:00
Cody Henthorne
f062e58f7b Flesh out monthly iDEAL donation flow. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
96aec401b9 Fix username link settings navigation. 2023-11-08 19:51:46 -05:00
Nicholas Tinsley
7ff0b7aa3c Increase clickable area of media download button. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
e5ab5241d5 Centralize username logic in UsernameRepository. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
0f4f87067e Add some detailed username docs to UsernameRepository. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
3f32f816b0 Convert the UsernameRepository to an object. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
73de2dfda7 Fix opening username links. 2023-11-08 19:51:46 -05:00
Nicholas
d6fd6cb5a3 Optimize thread ID DB query. 2023-11-08 19:51:46 -05:00
Nicholas
39fbbe896f Batch insert group receipts. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
29c70acf4e Leave attachment insert early if there are no attachments. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
5cd2568776 Fix foreground service crash with state tracking. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
60a6535a12 Add internal test buttons to corrupt username state. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
f48b389449 Fix padding in edit profile screen. 2023-11-08 19:51:46 -05:00
Greyson Parrelli
316dd210a0 Minor improvements to username tooltip. 2023-11-07 22:11:08 -05:00
Greyson Parrelli
a60712c09d If both usernames hashes are empty, consider valid. 2023-11-07 14:44:46 -05:00
Nicholas Tinsley
482cd564ff Lower priority of ConversationShortcutUpdateJob. 2023-11-07 13:37:21 -05:00
Greyson Parrelli
ac1171d43b Allow install of nightlies with the same version code but newer upload dates. 2023-11-07 12:51:09 -05:00
Greyson Parrelli
ed8953c430 Fix logging around username link reset failures. 2023-11-07 12:11:22 -05:00
Cody Henthorne
9a8aecaf3f Improve donation strings localization. 2023-11-07 11:56:01 -05:00
Greyson Parrelli
423719e7bc Fix username QR code sharing. 2023-11-07 11:43:40 -05:00
Cody Henthorne
7f2b6a874e Flesh out iDEAL sad path UX and address UI polish feedback. 2023-11-07 11:04:36 -05:00
Greyson Parrelli
cfe5ea3f9b Add the ability to download the current perfetto trace in Spinner. 2023-11-07 09:07:59 -05:00
Greyson Parrelli
07aa058a46 Update username consistency error handling. 2023-11-06 14:49:51 -05:00
Nicholas Tinsley
6cadf93c43 Forward touch events in timestamp of text message. 2023-11-06 14:48:35 -05:00
Cody Henthorne
60eb1332d2 Fix lifespan typo for ExternalLaunchDonationJob. 2023-11-06 11:04:24 -05:00
Nicholas Tinsley
a9ee7e93fd Increase IdentityKey cache size. 2023-11-06 10:46:53 -05:00
Clark
2782216e52 Remove slow getResourceAsStream when loading the Conscrypt provider. 2023-11-06 09:56:11 -05:00
Nicholas Tinsley
d22537c5f2 Fix LocalMetrics for text sends. 2023-11-03 15:24:36 -04:00
Nicholas Tinsley
57aa6c19e1 Set silent group updates to low job priority. 2023-11-03 15:20:38 -04:00
Nicholas Tinsley
761553d392 Avoid unnecessary lock acquisition. 2023-11-03 15:12:29 -04:00
Greyson Parrelli
29350ab7b0 Add a QR code link and tooltip in the profile settings. 2023-11-03 14:33:07 -04:00
Cody Henthorne
528ccc1e9d Navigate to main donation screen if user leaves for external app. 2023-11-03 12:56:03 -04:00
Cody Henthorne
20d26ad7ca Expand spinner timestamp conversion to job tables. 2023-11-03 12:51:17 -04:00
Cody Henthorne
5d23c5c902 Increase sepa receipt request lifespan to cover at least 14 business days. 2023-11-03 12:49:19 -04:00
Greyson Parrelli
145794bf04 Add the ability to set job priority. 2023-11-03 12:21:27 -04:00
Greyson Parrelli
d00f2aa8d0 Convert EditProfileFragment to kotlin. 2023-11-03 10:40:13 -04:00
Greyson Parrelli
3a20375567 Update profile edit screen to remove subtitles. 2023-11-03 09:25:09 -04:00
Greyson Parrelli
7be93a8a44 Rename profile fragments so they make sense. 2023-11-03 09:14:17 -04:00
Jim Gustafson
b5e4c4e92a Update to RingRTC v2.34.3 2023-11-02 21:30:07 -04:00
Greyson Parrelli
20285796bd Fix username link sharing toolbar. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
7826ff94e3 Also check PNI prekey age on message send. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
f1dccbb64d Consider empty usernames as absent. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
528e301ce4 Improve username creation error debouncing. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
af016a9c79 Fix username error message text wrapping. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
cbd5738543 Fix some username creation tinting issues in dark theme. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
2dd0899a3d Fix nightly updates. 2023-11-02 19:19:00 -04:00
Greyson Parrelli
e486a4baef Bump version to 6.39.1 2023-11-02 19:18:37 -04:00
Greyson Parrelli
5fc11baf9e Update translations and other static files. 2023-11-02 19:18:37 -04:00
Nicholas
157777cac1 Batch update DB upon group receipt. 2023-11-02 19:18:37 -04:00
Greyson Parrelli
99d0ee6725 Fix cursor crash in ConversationSettings.
Best way to fix a cursor crash it to... stop using cursors.

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

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

3
.gitignore vendored
View File

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

View File

@@ -33,8 +33,8 @@ ktlint {
version = "0.49.1"
}
def canonicalVersionCode = 1351
def canonicalVersionName = "6.38.0"
def canonicalVersionCode = 1365
def canonicalVersionName = "6.41.3"
def postFixSize = 100
def abiPostFix = ['universal' : 0,
@@ -320,22 +320,26 @@ android {
dimension 'distribution'
isDefault true
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
buildConfigField "String", "APK_UPDATE_URL", "null"
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "null"
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
}
website {
dimension 'distribution'
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
buildConfigField "String", "APK_UPDATE_URL", "\"https://updates.signal.org/android\""
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
}
nightly {
def apkUpdateManifestUrl = "<unset>"
if (file("${project.rootDir}/nightly-url.txt").exists()) {
apkUpdateManifestUrl = file("${project.rootDir}/nightly-url.txt").text.trim()
}
dimension 'distribution'
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
buildConfigField "String", "APK_UPDATE_URL", "null"
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\""
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
}
@@ -400,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")
@@ -659,6 +666,24 @@ 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 (!file("${project.rootDir}/nightly-url.txt").exists()) {
throw new GradleException("Cannot fine 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
}
}
}
}
def loadKeystoreProperties(filename) {
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
if (keystorePropertiesFile.exists()) {

View File

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

View File

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

View File

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

View File

@@ -963,7 +963,7 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
<activity android:name=".profiles.edit.EditProfileActivity"
<activity android:name=".profiles.edit.CreateProfileActivity"
android:theme="@style/TextSecure.LightRegistrationTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
@@ -973,7 +973,7 @@
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
<activity android:name=".profiles.manage.ManageProfileActivity"
<activity android:name=".profiles.manage.EditProfileActivity"
android:theme="@style/TextSecure.LightTheme"
android:windowSoftInputMode="stateVisible|adjustResize"
android:exported="false"/>
@@ -1189,6 +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"/>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -26,10 +26,11 @@ import androidx.multidex.MultiDexApplication;
import com.google.android.gms.security.ProviderInstaller;
import org.conscrypt.Conscrypt;
import org.conscrypt.ConscryptSignal;
import org.greenrobot.eventbus.EventBus;
import org.signal.aesgcmprovider.AesGcmProvider;
import org.signal.core.util.MemoryTracker;
import org.signal.core.util.concurrent.AnrDetector;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.AndroidLogger;
import org.signal.core.util.logging.Log;
@@ -109,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;
/**
@@ -151,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)
@@ -165,7 +168,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addBlocking("proxy-init", () -> {
if (SignalStore.proxy().isProxyEnabled()) {
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
Conscrypt.setUseEngineSocketByDefault(true);
ConscryptSignal.setUseEngineSocketByDefault(true);
}
})
.addBlocking("blob-provider", this::initializeBlobProvider)
@@ -227,6 +230,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
ExternalLaunchDonationJob.enqueueIfNecessary();
FcmFetchManager.onForeground(this);
startAnrDetector();
SignalExecutors.BOUNDED.execute(() -> {
FeatureFlags.refreshIfNecessary();
@@ -260,6 +264,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getShakeToReport().disable();
ApplicationDependencies.getDeadlockDetector().stop();
MemoryTracker.stop();
AnrDetector.stop();
}
public void checkBuildExpiration() {
@@ -269,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);
@@ -278,7 +294,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
throw new ProviderInitializationException();
}
int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2);
int conscryptPosition = Security.insertProviderAt(ConscryptSignal.newProvider(), 2);
Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition);
if (conscryptPosition < 0) {
@@ -410,6 +426,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
}
if (!SignalStore.internalValues().callingDisableLBRed()) {
fieldTrials.put("RingRTC-Audio-LBRed-For-Opus", "Enabled,bitrate_pri:22000");
}
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
} catch (UnsatisfiedLinkError e) {
throw new AssertionError("Unable to load ringrtc library", e);

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
@@ -228,7 +228,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getCreateProfileNameIntent() {
Intent intent = EditProfileActivity.getIntentForUserProfile(this);
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
return getRoutedIntent(intent, getIntent());
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.AppUtil
import org.signal.core.util.Hex
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.logging.Log
@@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.JobDatabase
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
import org.thoughtcrime.securesms.database.LogDatabase
import org.thoughtcrime.securesms.database.MegaphoneDatabase
@@ -48,13 +50,19 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.payments.DataExportUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.ConversationUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.random.Random
import kotlin.random.nextInt
import kotlin.time.Duration.Companion.seconds
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
@@ -171,6 +179,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Clear all logs"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).logs.clearAll()
}) {
Toast.makeText(requireContext(), "Cleared all logs", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Clear keep longer logs"),
onClick = {
@@ -178,6 +197,28 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Clear all crashes"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).crashes.clear()
}) {
Toast.makeText(requireContext(), "Cleared crashes", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Clear all ANRs"),
onClick = {
SimpleTask.run({
LogDatabase.getInstance(requireActivity().application).anrs.clear()
}) {
Toast.makeText(requireContext(), "Cleared ANRs", Toast.LENGTH_SHORT).show()
}
}
)
clickPref(
title = DSLSettingsText.from("Log dump PreKey ServiceId-KeyIds"),
onClick = {
@@ -185,6 +226,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Retry all jobs now"),
summary = DSLSettingsText.from("Clear backoff intervals, app will restart"),
onClick = {
SimpleTask.run({
JobDatabase.getInstance(ApplicationDependencies.getApplication()).debugResetBackoffInterval()
}) {
AppUtil.restart(requireContext())
}
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Payments"))
@@ -439,9 +492,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
if (SignalStore.donationsValues().getSubscriber() != null) {
dividerPref()
switchPref(
title = DSLSettingsText.from("Disable LBRed"),
isChecked = state.callingDisableLBRed,
onClick = {
viewModel.setInternalCallingDisableLBRed(!state.callingDisableLBRed)
}
)
dividerPref()
if (SignalStore.donationsValues().getSubscriber() != null) {
sectionHeaderPref(DSLSettingsText.from("Badges"))
clickPref(
@@ -474,6 +535,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
}
)
dividerPref()
}
if (state.hasPendingOneTimeDonation) {
@@ -538,6 +601,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Add remote donate megaphone"),
onClick = {
viewModel.addRemoteDonateMegaphone()
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("CDS"))
@@ -639,6 +709,47 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
}
)
clickPref(
title = DSLSettingsText.from("Corrupt username"),
summary = DSLSettingsText.from("Changes our local username without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Corrupt your username?")
.setMessage("Are you sure? You might not be able to get your original username back.")
.setPositiveButton(android.R.string.ok) { _, _ ->
val random = "${Hex.toStringCondensed(Util.getSecretBytes(4))}.${Random.nextInt(1, 100)}"
SignalStore.account().username = random
SignalDatabase.recipients.setUsername(Recipient.self().id, random)
StorageSyncHelper.scheduleSyncForDataChange()
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
)
clickPref(
title = DSLSettingsText.from("Corrupt username link"),
summary = DSLSettingsText.from("Changes our local username link without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Corrupt your username link?")
.setMessage("Are you sure? You'll have to reset your link.")
.setPositiveButton(android.R.string.ok) { _, _ ->
SignalStore.account().usernameLink = UsernameLinkComponents(
entropy = Util.getSecretBytes(32),
serverId = SignalStore.account().usernameLink?.serverId ?: UUID.randomUUID()
)
StorageSyncHelper.scheduleSyncForDataChange()
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
}
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.show()
}
)
dividerPref()
sectionHeaderPref(DSLSettingsText.from("Chat Filters"))
clickPref(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
@@ -156,13 +158,15 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun onUserLaunchedAnExternalApplication() = Unit
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
}
})
}
}
@@ -270,6 +274,7 @@ private fun BankTransferDetailsContent(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.defaultMinSize(minHeight = 78.dp)
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
.focusRequester(focusRequester)
)
@@ -289,9 +294,11 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
@@ -309,16 +316,20 @@ private fun BankTransferDetailsContent(
keyboardActions = KeyboardActions(
onDone = { onDonateClick() }
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
item {
Box(
contentAlignment = Center,
modifier = Modifier.fillMaxWidth()
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
) {
TextButton(
onClick = { setDisplayFindAccountInfoSheet(true) }
@@ -334,7 +345,7 @@ private fun BankTransferDetailsContent(
onClick = onDonateClick,
modifier = Modifier
.defaultMinSize(minWidth = 220.dp)
.padding(bottom = 16.dp)
.padding(vertical = 16.dp)
) {
Text(text = donateLabel)
}

View File

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

View File

@@ -42,6 +42,8 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
@@ -147,13 +149,21 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
)
}
override fun onUserCancelledPaymentFlow() = Unit
override fun onUserLaunchedAnExternalApplication() {
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, true)
}
})
}
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
findNavController().popBackStack()
findNavController().popBackStack()
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
findNavController().popBackStack(R.id.donateToSignalFragment, false)
}
})
}
}
@@ -221,7 +231,6 @@ private fun IdealTransferDetailsContent(
onSelectBankClick = onSelectBankClick,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
}
@@ -239,9 +248,11 @@ private fun IdealTransferDetailsContent(
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
@@ -263,9 +274,11 @@ private fun IdealTransferDetailsContent(
}
}
),
supportingText = {},
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
.padding(top = 16.dp)
.defaultMinSize(minHeight = 78.dp)
)
}
}
@@ -329,7 +342,9 @@ private fun IdealBankSelector(
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
),
supportingText = {},
modifier = modifier
.defaultMinSize(minHeight = 78.dp)
.clickable(
onClick = onSelectBankClick,
role = Role.Button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ data class ManageDonationsState(
val subscriptionTransactionState: TransactionState = TransactionState.Init,
val availableSubscriptions: List<Subscription> = emptyList(),
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE
) {
@@ -26,7 +27,7 @@ data class ManageDonationsState(
private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? {
return when {
activeSubscription.isFailedPayment -> RedemptionState.FAILED
activeSubscription.isFailedPayment && !activeSubscription.isPastDue -> RedemptionState.FAILED
activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER
activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS
else -> null

View File

@@ -14,13 +14,13 @@ import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.subscription.LevelUpdate
import org.thoughtcrime.securesms.util.InternetConnectionObserver
import org.thoughtcrime.securesms.util.livedata.Store
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
import java.util.Optional
class ManageDonationsViewModel(
private val subscriptionsRepository: MonthlyDonationRepository
@@ -76,16 +76,27 @@ class ManageDonationsViewModel(
store.update { it.copy(hasReceipts = hasReceipts) }
}
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { jobStateOptional ->
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { redemptionStatus ->
store.update { manageDonationsState ->
manageDonationsState.copy(
subscriptionRedemptionState = jobStateOptional.map(this::mapJobStateToRedemptionState).orElse(ManageDonationsState.RedemptionState.NONE)
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
subscriptionRedemptionState = mapStatusToRedemptionState(redemptionStatus)
)
}
}
disposables += SignalStore.donationsValues()
.observablePendingOneTimeDonation
disposables += Observable.combineLatest(
SignalStore.donationsValues().observablePendingOneTimeDonation,
DonationRedemptionJobWatcher.watchOneTimeRedemption()
) { pendingFromStore, pendingFromJob ->
if (pendingFromStore.isPresent) {
pendingFromStore
} else if (pendingFromJob is DonationRedemptionJobStatus.PendingExternalVerification) {
Optional.ofNullable(pendingFromJob.pendingOneTimeDonation)
} else {
Optional.empty()
}
}
.distinctUntilChanged()
.subscribeBy { pending ->
store.update { it.copy(pendingOneTimeDonation = pending.orNull()) }
@@ -122,13 +133,14 @@ class ManageDonationsViewModel(
)
}
private fun mapJobStateToRedemptionState(jobState: JobTracker.JobState): ManageDonationsState.RedemptionState {
return when (jobState) {
JobTracker.JobState.PENDING -> ManageDonationsState.RedemptionState.IN_PROGRESS
JobTracker.JobState.RUNNING -> ManageDonationsState.RedemptionState.IN_PROGRESS
JobTracker.JobState.SUCCESS -> ManageDonationsState.RedemptionState.NONE
JobTracker.JobState.FAILURE -> ManageDonationsState.RedemptionState.FAILED
JobTracker.JobState.IGNORED -> ManageDonationsState.RedemptionState.NONE
private fun mapStatusToRedemptionState(status: DonationRedemptionJobStatus): ManageDonationsState.RedemptionState {
return when (status) {
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
is DonationRedemptionJobStatus.PendingExternalVerification,
DonationRedemptionJobStatus.PendingReceiptRedemption,
DonationRedemptionJobStatus.PendingReceiptRequest -> ManageDonationsState.RedemptionState.IN_PROGRESS
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
import org.signal.core.util.money.FiatMoney
/**
* Represents a monthly donation via iDEAL that is still pending user verification in
* their 3rd party app.
*/
data class NonVerifiedMonthlyDonation(
val timestamp: Long,
val price: FiatMoney,
val level: Int,
val checkedVerification: Boolean
)

View File

@@ -32,7 +32,7 @@ class DonationReceiptListFragment : Fragment(R.layout.donation_receipt_list_frag
0 -> R.string.DonationReceiptListFragment__all
1 -> R.string.DonationReceiptListFragment__recurring
2 -> R.string.DonationReceiptListFragment__one_time
3 -> R.string.DonationReceiptListFragment__donation_for_a_friend
3 -> R.string.DonationReceiptListFragment__for_a_friend
else -> error("Unsupported index $position")
}
)

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.updates
import android.os.Build
import org.thoughtcrime.securesms.R
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.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Settings around app updates. Only shown for builds that manage their own app updates.
*/
class AppUpdatesSettingsFragment : DSLSettingsFragment(R.string.preferences_app_updates__title) {
override fun bindAdapter(adapter: MappingAdapter) {
adapter.submitList(getConfiguration().toMappingModelList())
}
private fun getConfiguration(): DSLConfiguration {
return configure {
if (Build.VERSION.SDK_INT >= 31) {
switchPref(
title = DSLSettingsText.from("Automatic updates"),
summary = DSLSettingsText.from("Automatically download and install app updates"),
isChecked = SignalStore.apkUpdate().autoUpdate,
onClick = {
SignalStore.apkUpdate().autoUpdate = !SignalStore.apkUpdate().autoUpdate
}
)
}
clickPref(
title = DSLSettingsText.from("Check for updates"),
summary = DSLSettingsText.from("Last checked on: $lastSuccessfulUpdateString"),
onClick = {
ApplicationDependencies.getJobManager().add(ApkUpdateJob())
}
)
}
}
private val lastSuccessfulUpdateString: String
get() {
val lastUpdateTime = SignalStore.apkUpdate().lastSuccessfulCheck
return if (lastUpdateTime > 0) {
val dateFormat = SimpleDateFormat("MMMM dd, yyyy 'at' h:mma", Locale.US)
dateFormat.format(Date(lastUpdateTime))
} else {
"Never"
}
}
}

View File

@@ -53,12 +53,12 @@ fun QrCode(
}
}
private fun DrawScope.drawQr(
fun DrawScope.drawQr(
data: QrCodeData,
foregroundColor: Color,
backgroundColor: Color,
deadzonePercent: Float,
logo: ImageBitmap
logo: ImageBitmap?
) {
val deadzonePaddingPercent = 0.045f
@@ -120,12 +120,14 @@ private fun DrawScope.drawQr(
// Logo
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
)
if (logo != null) {
drawImage(
image = logo,
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
dstSize = IntSize(logoWidthPx, logoWidthPx),
colorFilter = ColorFilter.tint(foregroundColor)
)
}
}
@Preview

View File

@@ -24,17 +24,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -45,8 +40,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.compose.getScreenshotBounds
/**
* Renders a QR code and username as a badge.
@@ -57,23 +50,16 @@ fun QrCodeBadge(
colorScheme: UsernameQrCodeColorScheme,
username: String,
modifier: Modifier = Modifier,
screenshotController: ScreenshotController? = null,
usernameCopyable: Boolean = false,
onClick: (() -> Unit) = {}
onClick: ((String) -> Unit) = {}
) {
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border")
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground")
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation")
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor")
var badgeBounds by remember {
mutableStateOf<Rect?>(null)
}
screenshotController?.bind(LocalView.current, badgeBounds)
val textColor by animateColorAsState(targetValue = colorScheme.textColor, label = "textColor")
Surface(
modifier = modifier
.onGloballyPositioned {
badgeBounds = it.getScreenshotBounds()
},
modifier = modifier,
color = borderColor,
shape = RoundedCornerShape(24.dp),
shadowElevation = elevation.dp
@@ -99,8 +85,8 @@ fun QrCodeBadge(
data = data.data,
modifier = Modifier
.border(
width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp,
color = Color(0xFFE9E9E9),
width = 2.dp,
color = colorScheme.outlineColor,
shape = RoundedCornerShape(size = 12.dp)
)
.padding(16.dp),
@@ -146,7 +132,7 @@ fun QrCodeBadge(
.clip(RoundedCornerShape(8.dp))
.clickable(
enabled = usernameCopyable,
onClick = onClick
onClick = { onClick(username) }
)
.padding(8.dp)
) {

View File

@@ -8,6 +8,8 @@ import androidx.compose.ui.graphics.Color
enum class UsernameQrCodeColorScheme(
val borderColor: Color,
val foregroundColor: Color,
val textColor: Color = Color.White,
val outlineColor: Color = Color.Transparent,
private val key: String
) {
Blue(
@@ -18,6 +20,8 @@ enum class UsernameQrCodeColorScheme(
White(
borderColor = Color(0xFFFFFFFF),
foregroundColor = Color(0xFF000000),
textColor = Color.Black,
outlineColor = Color(0xFFE9E9E9),
key = "white"
),
Grey(

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -15,6 +14,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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
@@ -104,7 +104,11 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Image(painter = painterResource(R.drawable.symbol_arrow_left_24), contentDescription = null)
Icon(
painter = painterResource(R.drawable.symbol_arrow_left_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)

View File

@@ -15,9 +15,9 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeSt
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.UsernameUtil
class UsernameLinkQrColorPickerViewModel : ViewModel() {
@@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
if (usernameLink != null) {
disposable += Single
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
.fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Intent
@@ -9,22 +11,26 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,15 +38,19 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
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.app.ShareCompat
import androidx.fragment.app.setFragmentResultListener
import androidx.fragment.app.viewModels
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.CoroutineScope
@@ -51,20 +61,26 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.providers.BlobProvider
import java.io.ByteArrayOutputStream
import java.util.UUID
@OptIn(
ExperimentalPermissionsApi::class
)
@OptIn(ExperimentalPermissionsApi::class)
class UsernameLinkSettingsFragment : ComposeFragment() {
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
private val disposables: LifecycleDisposable = LifecycleDisposable()
private val screenshotController = ScreenshotController()
override fun onStart() {
super.onStart()
setFragmentResultListener(UsernameLinkShareBottomSheet.REQUEST_KEY) { key, bundle ->
if (bundle.getBoolean(UsernameLinkShareBottomSheet.KEY_COPY)) {
viewModel.onLinkCopied()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun FragmentContent() {
val state by viewModel.state
@@ -72,10 +88,30 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
val scope: CoroutineScope = rememberCoroutineScope()
val navController: NavController by remember { mutableStateOf(findNavController()) }
var showResetDialog: Boolean by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent
val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
LaunchedEffect(linkCopiedEvent) {
if (linkCopiedEvent != null) {
snackbarHostState.showSnackbar(linkCopiedString)
}
}
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
topBar = { TopAppBarContent(state.activeTab) }
topBar = {
TopAppBarContent(
activeTab = state.activeTab,
scrollBehavior = scrollBehavior,
onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) },
onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) },
cameraPermissionState = cameraPermissionState
)
},
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
) { contentPadding ->
if (state.indeterminateProgress) {
@@ -94,9 +130,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
modifier = Modifier.padding(contentPadding),
navController = navController,
onShareBadge = {
shareQrBadge(it)
shareQrBadge(viewModel.generateQrCodeImage())
},
screenshotController = screenshotController,
onResetClicked = { showResetDialog = true },
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }
)
@@ -138,41 +173,57 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
viewModel.onResume()
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBarContent(activeTab: ActiveTab) {
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
Box(
private fun TopAppBarContent(
activeTab: ActiveTab,
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
onCodeTabSelected: () -> Unit = {},
onScanTabSelected: () -> Unit = {},
cameraPermissionState: PermissionState = previewPermissionState()
) {
CenterAlignedTopAppBar(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
TabButton(
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
active = activeTab == ActiveTab.Code,
onClick = { viewModel.onTabSelected(ActiveTab.Code) },
modifier = Modifier.padding(end = 8.dp)
)
TabButton(
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
active = activeTab == ActiveTab.Scan,
onClick = {
if (cameraPermissionState.status.isGranted) {
viewModel.onTabSelected(ActiveTab.Scan)
} else {
cameraPermissionState.launchPermissionRequest()
}
}
)
}
}
.fillMaxWidth(),
title = {
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
TabButton(
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
active = activeTab == ActiveTab.Code,
onClick = onCodeTabSelected,
modifier = Modifier.padding(end = 8.dp)
)
TabButton(
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
active = activeTab == ActiveTab.Scan,
onClick = {
if (cameraPermissionState.status.isGranted) {
onScanTabSelected()
} else {
cameraPermissionState.launchPermissionRequest()
}
},
modifier = Modifier.padding(end = 8.dp)
)
}
},
navigationIcon = {
IconButton(
onClick = { requireActivity().onNavigateUp() }
) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
contentDescription = stringResource(android.R.string.cancel)
)
}
},
scrollBehavior = scrollBehavior
)
}
@Composable
@@ -185,7 +236,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
contentColor = MaterialTheme.colorScheme.onSurface
)
}
Buttons.MediumTonal(
Buttons.Small(
onClick = onClick,
modifier = modifier.defaultMinSize(minWidth = 100.dp),
shape = RoundedCornerShape(12.dp),
@@ -234,7 +285,19 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
}
}
private fun shareQrBadge(badge: Bitmap) {
private fun previewPermissionState(): PermissionState {
return object : PermissionState {
override val permission: String = ""
override val status: PermissionStatus = PermissionStatus.Granted
override fun launchPermissionRequest() = Unit
}
}
private fun shareQrBadge(badge: Bitmap?) {
if (badge == null) {
return
}
try {
ByteArrayOutputStream().use { byteArrayOutputStream ->
badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)

View File

@@ -1,7 +1,24 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.Typeface
import android.os.Build
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
@@ -10,17 +27,20 @@ import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.drawQr
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.NetworkUtil
import org.thoughtcrime.securesms.util.UsernameUtil
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
import java.util.Optional
import java.util.UUID
class UsernameLinkSettingsViewModel : ViewModel() {
@@ -30,7 +50,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
UsernameLinkSettingsState(
activeTab = ActiveTab.Code,
username = SignalStore.account().username!!,
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(it.toLink()) } ?: UsernameLinkState.NotSet,
qrCodeState = QrCodeState.Loading,
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
)
@@ -39,12 +59,14 @@ class UsernameLinkSettingsViewModel : ViewModel() {
private val disposable: CompositeDisposable = CompositeDisposable()
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
private val usernameRepo: UsernameRepository = UsernameRepository()
private val _linkCopiedEvent: MutableState<UUID?> = mutableStateOf(null)
val linkCopiedEvent: State<UUID?> get() = _linkCopiedEvent
init {
disposable += usernameLink
.observeOn(Schedulers.io())
.map { link -> link.map { UsernameUtil.generateLink(it) } }
.map { link -> link.map { it.toLink() } }
.flatMapSingle { generateQrCodeData(it) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { qrData ->
@@ -52,6 +74,10 @@ class UsernameLinkSettingsViewModel : ViewModel() {
qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet
)
}
if (SignalStore.account().usernameLink == null) {
onUsernameLinkReset()
}
}
override fun onCleared() {
@@ -90,7 +116,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
qrCodeState = QrCodeState.Loading
)
disposable += usernameRepo.createOrResetUsernameLink()
disposable += UsernameRepository.createOrResetUsernameLink()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result ->
val components: Optional<UsernameLinkComponents> = when (result) {
@@ -101,18 +127,22 @@ class UsernameLinkSettingsViewModel : ViewModel() {
_state.value = _state.value.copy(
usernameLinkState = if (components.isPresent) {
val link = UsernameUtil.generateLink(components.get())
val link = components.get().toLink()
UsernameLinkState.Present(link)
} else {
UsernameLinkState.NotSet
},
usernameLinkResetResult = result,
qrCodeState = if (components.isPresent && previousQrValue != null) {
qrCodeState = if (!components.isPresent && previousQrValue != null) {
QrCodeState.Present(previousQrValue)
} else {
QrCodeState.NotSet
}
)
if (components.isPresent) {
usernameLink.onNext(components)
}
}
}
@@ -127,7 +157,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
indeterminateProgress = true
)
disposable += usernameRepo.convertLinkToUsernameAndAci(url)
disposable += UsernameRepository.fetchUsernameAndAciFromLink(url)
.map { result ->
when (result) {
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
@@ -152,9 +182,115 @@ class UsernameLinkSettingsViewModel : ViewModel() {
)
}
fun onLinkCopied() {
_linkCopiedEvent.value = UUID.randomUUID()
}
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
return Single.fromCallable {
url.map { QrCodeData.forData(it, 64) }
}
}
/**
* Fun fact: there's no way to draw a composable to a bitmap. You'd think there would be, but there isn't. You can "screenshot" it if it's 100% on-screen,
* but if it's partially offscreen you're SOL. So, we get to go through the fun process of re-drawing the QR badge to an image for sharing ourselves.
*
* Sizes were picked arbitrarily.
*
* I hate this as much as you do.
*/
fun generateQrCodeImage(): Bitmap? {
val state: UsernameLinkSettingsState = _state.value
if (state.qrCodeState !is QrCodeState.Present) {
Log.w(TAG, "Invalid state to generate QR code! ${state.qrCodeState.javaClass.simpleName}")
return null
}
val qrCodeData: QrCodeData = state.qrCodeState.data
val width = 480
val height = 525
val qrSize = 300f
val qrPadding = 25f
val borderSizeX = 64f
val borderSizeY = 52f
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
eraseColor(Color.TRANSPARENT)
}
val androidCanvas = android.graphics.Canvas(bitmap)
val composeCanvas = Canvas(androidCanvas)
val canvasDrawScope = CanvasDrawScope()
// Draw the background
androidCanvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 30f, 30f, Paint().apply { color = state.qrCodeColorScheme.borderColor.toArgb() })
androidCanvas.drawRoundRect(borderSizeX, borderSizeY, borderSizeX + qrSize + qrPadding * 2, borderSizeY + qrSize + qrPadding * 2, 15f, 15f, Paint().apply { color = Color.WHITE })
androidCanvas.drawRoundRect(
borderSizeX,
borderSizeY,
borderSizeX + qrSize + qrPadding * 2,
borderSizeY + qrSize + qrPadding * 2,
15f,
15f,
Paint().apply {
color = state.qrCodeColorScheme.outlineColor.toArgb()
style = Paint.Style.STROKE
strokeWidth = 4f
}
)
// Draw the QR code
composeCanvas.translate((width / 2) - (qrSize / 2), 80f)
canvasDrawScope.draw(
density = object : Density {
override val density: Float = 1f
override val fontScale: Float = 1f
},
layoutDirection = LayoutDirection.Ltr,
canvas = composeCanvas,
size = Size(qrSize, qrSize)
) {
drawQr(
data = qrCodeData,
foregroundColor = state.qrCodeColorScheme.foregroundColor,
backgroundColor = state.qrCodeColorScheme.borderColor,
deadzonePercent = 0.35f,
logo = null
)
}
composeCanvas.translate(-90f, -80f)
// Draw the signal logo -- unfortunately can't have the normal QR code drawing handle it because it requires a composable ImageBitmap
BitmapFactory.decodeResource(ApplicationDependencies.getApplication().resources, R.drawable.qrcode_logo).also { logoBitmap ->
val tintedPaint = Paint().apply {
colorFilter = PorterDuffColorFilter(state.qrCodeColorScheme.foregroundColor.toArgb(), PorterDuff.Mode.SRC_IN)
}
val sourceRect = Rect(0, 0, logoBitmap.width, logoBitmap.height)
val destRect = RectF(210f, 200f, 270f, 260f)
androidCanvas.drawBitmap(logoBitmap, sourceRect, destRect, tintedPaint)
}
// Draw the text
val textPaint = Paint().apply {
color = state.qrCodeColorScheme.textColor.toArgb()
textSize = 34f
typeface = if (Build.VERSION.SDK_INT < 26) {
Typeface.DEFAULT_BOLD
} else {
Typeface.Builder("")
.setFallback("sans-serif")
.setWeight(600)
.build()
}
}
val textBounds = Rect()
textPaint.getTextBounds(state.username, 0, state.username.length, textBounds)
androidCanvas.drawText(state.username, (width / 2f) - (textBounds.width() / 2f), 465f, textPaint)
return bitmap
}
}

View File

@@ -0,0 +1,169 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.Intent
import android.content.res.Configuration
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
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.FragmentManager
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.Util
class UsernameLinkShareBottomSheet : ComposeBottomSheetDialogFragment() {
companion object {
const val REQUEST_KEY = "link_share_bottom_sheet"
const val KEY_COPY = "copy"
@JvmStatic
fun show(fragmentManager: FragmentManager) {
CallLinkIncomingRequestSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
@Composable
override fun SheetContent() {
Content(
usernameLink = SignalStore.account().usernameLink?.toLink() ?: "",
dismissDialog = { didCopy ->
setFragmentResult(REQUEST_KEY, bundleOf(KEY_COPY to didCopy))
dismiss()
}
)
}
}
@Composable
private fun Content(
usernameLink: String,
dismissDialog: (Boolean) -> Unit = {}
) {
val context = LocalContext.current
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheets.Handle()
Text(
text = stringResource(R.string.UsernameLinkShareBottomSheet_title),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(horizontal = 41.dp, vertical = 24.dp)
)
Text(
text = usernameLink,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 24.dp)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
shape = RoundedCornerShape(12.dp)
)
.padding(all = 16.dp)
)
ButtonRow(
icon = painterResource(R.drawable.symbol_copy_android_24),
text = stringResource(R.string.UsernameLinkShareBottomSheet_copy_link),
modifier = Modifier.padding(top = 12.dp),
onClick = {
Util.copyToClipboard(context, usernameLink)
dismissDialog(true)
}
)
ButtonRow(
icon = painterResource(R.drawable.symbol_share_android_24),
text = stringResource(R.string.UsernameLinkShareBottomSheet_share),
modifier = Modifier.padding(bottom = 12.dp),
onClick = {
dismissDialog(false)
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, usernameLink)
}
context.startActivity(Intent.createChooser(sendIntent, null))
}
)
}
}
@Composable
private fun ButtonRow(icon: Painter, text: String, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Icon(
painter = icon,
contentDescription = text,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
)
Text(
text = text,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(vertical = 16.dp)
)
}
}
@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ContentPreview() {
SignalTheme {
Surface {
Content(
usernameLink = "https://signal.me#eufzLWmFFUYAOqnVJ4Zlt0KqXf87r59FC1hZ3r7WipjKvgzMBg7DBlY5DB5hQTjsw0"
)
}
}
}
@Preview(name = "Light Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ButtonRowPreview() {
SignalTheme {
Surface {
ButtonRow(icon = painterResource(R.drawable.symbol_share_android_24), text = "Share")
}
}
}

View File

@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
import android.content.res.Configuration
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeDa
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
import org.thoughtcrime.securesms.compose.ScreenshotController
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -57,11 +55,12 @@ fun UsernameLinkShareScreen(
snackbarHostState: SnackbarHostState,
scope: CoroutineScope,
navController: NavController,
onShareBadge: (Bitmap) -> Unit,
onShareBadge: () -> Unit,
modifier: Modifier = Modifier,
screenshotController: ScreenshotController? = null,
onResetClicked: () -> Unit
) {
val context = LocalContext.current
when (state.usernameLinkResetResult) {
UsernameLinkResetResult.NetworkUnavailable -> {
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled)
@@ -82,10 +81,10 @@ fun UsernameLinkShareScreen(
data = state.qrCodeState,
colorScheme = state.qrCodeColorScheme,
username = state.username,
screenshotController = screenshotController,
usernameCopyable = true,
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp),
onClick = {
onClick = { username ->
Util.copyToClipboard(context, username)
scope.launch {
snackbarHostState.showSnackbar(usernameCopiedString)
}
@@ -93,19 +92,15 @@ fun UsernameLinkShareScreen(
)
ButtonBar(
onShareClicked = {
val badgeBitmap = screenshotController?.screenshot()
if (badgeBitmap != null) {
onShareBadge.invoke(badgeBitmap)
}
},
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
onShareClicked = onShareBadge,
onColorClicked = { navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) }
)
LinkRow(
linkState = state.usernameLinkState,
snackbarHostState = snackbarHostState,
scope = scope
onClick = {
navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet())
}
)
Text(
@@ -151,9 +146,7 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
}
@Composable
private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val context = LocalContext.current
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
private fun LinkRow(linkState: UsernameLinkState, onClick: () -> Unit = {}) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -170,11 +163,7 @@ private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHos
shape = RoundedCornerShape(12.dp)
)
.clickable(enabled = linkState is UsernameLinkState.Present) {
Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link)
scope.launch {
snackbarHostState.showSnackbar(copyMessage)
}
onClick()
}
.padding(horizontal = 26.dp, vertical = 16.dp)
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
@@ -234,19 +223,13 @@ private fun LinkRowPreview() {
Surface {
Column(modifier = Modifier.padding(8.dp)) {
LinkRow(
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf")
)
LinkRow(
linkState = UsernameLinkState.NotSet,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
linkState = UsernameLinkState.NotSet
)
LinkRow(
linkState = UsernameLinkState.Resetting,
snackbarHostState = SnackbarHostState(),
scope = rememberCoroutineScope()
linkState = UsernameLinkState.Resetting
)
}
}

View File

@@ -32,8 +32,4 @@ sealed class ConversationSettingsEvent {
class ShowMembersAdded(
val membersAddedCount: Int
) : ConversationSettingsEvent()
class InitiateGroupMigration(
val recipientId: RecipientId
) : ConversationSettingsEvent()
}

View File

@@ -72,10 +72,9 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndR
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientExporter
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -199,7 +198,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
val groupId = args.groupId as ParcelableGroupId
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
true
} else {
super.onOptionsItemSelected(item)
@@ -279,7 +278,6 @@ class ConversationSettingsFragment : DSLSettingsFragment(
is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event)
is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event)
is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event)
is ConversationSettingsEvent.InitiateGroupMigration -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(parentFragmentManager, event.recipientId)
}
}
}
@@ -351,7 +349,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify,
canEditGroupAttributes = groupState.canEditGroupAttributes,
onEditGroupDescription = {
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
},
onViewGroupDescription = {
GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify)
@@ -363,7 +361,6 @@ class ConversationSettingsFragment : DSLSettingsFragment(
LegacyGroupPreference.Model(
state = groupState.legacyGroupState,
onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) },
onUpgradeClick = { viewModel.initiateGroupUpgrade() },
onMmsWarningClick = { startActivity(Intent(requireContext(), InviteActivity::class.java)) }
)
)
@@ -555,7 +552,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
}
}
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
if (state.sharedMedia.isNotEmpty()) {
dividerPref()
sectionHeaderPref(R.string.recipient_preference_activity__shared_media)
@@ -563,7 +560,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
@Suppress("DEPRECATION")
customPref(
SharedMediaPreference.Model(
mediaCursor = state.sharedMedia,
mediaRecords = state.sharedMedia,
mediaIds = state.sharedMediaIds,
onMediaRecordClick = { view, mediaRecord, isLtr ->
view.transitionName = "thumb"

View File

@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import java.io.IOException
import java.util.Optional
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
@@ -56,11 +55,11 @@ class ConversationSettingsRepository(
}
@WorkerThread
fun getThreadMedia(threadId: Long): Optional<Cursor> {
return if (threadId <= 0) {
Optional.empty()
fun getThreadMedia(threadId: Long, limit: Int): Cursor? {
return if (threadId > 0) {
SignalDatabase.media.getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest, limit)
} else {
Optional.of(SignalDatabase.media.getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest))
null
}
}

View File

@@ -1,9 +1,9 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.database.Cursor
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.groups.GroupId
@@ -18,7 +18,7 @@ data class ConversationSettingsState(
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
val disappearingMessagesLifespan: Int = 0,
val canModifyBlockedState: Boolean = false,
val sharedMedia: Cursor? = null,
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
val sharedMediaIds: List<Long> = listOf(),
val displayInternalRecipientDetails: Boolean = false,
val calls: List<CallPreference.Model> = emptyList(),

View File

@@ -13,13 +13,13 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.CursorUtil
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.readToList
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.RecipientTable
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -30,11 +30,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Optional
sealed class ConversationSettingsViewModel(
private val callMessageIds: LongArray,
@@ -42,8 +40,6 @@ sealed class ConversationSettingsViewModel(
specificSettingsState: SpecificSettingsState
) : ViewModel() {
private val openedMediaCursors = HashSet<Cursor>()
@Volatile
private var cleared = false
@@ -66,37 +62,26 @@ sealed class ConversationSettingsViewModel(
val threadId: LiveData<Long> = state.map { it.threadId }.distinctUntilChanged()
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
repository.getThreadMedia(tId)
val sharedMedia: LiveData<List<MediaTable.MediaRecord>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
repository.getThreadMedia(threadId = tId, limit = 100)?.readToList { cursor ->
MediaTable.MediaRecord.from(cursor)
} ?: emptyList()
}
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
}
store.update(sharedMedia) { cursor, state ->
store.update(sharedMedia) { mediaRecords, state ->
if (!cleared) {
if (cursor.isPresent) {
openedMediaCursors.add(cursor.get())
}
val ids: List<Long> = cursor.map<List<Long>> {
val result = mutableListOf<Long>()
while (it.moveToNext()) {
result.add(CursorUtil.requireLong(it, AttachmentTable.ROW_ID))
}
result
}.orElse(listOf())
state.copy(
sharedMedia = cursor.orElse(null),
sharedMediaIds = ids,
sharedMedia = mediaRecords,
sharedMediaIds = mediaRecords.mapNotNull { it.attachment?.attachmentId?.rowId },
sharedMediaLoaded = true,
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
)
} else {
cursor.orElse(null).ensureClosed()
state.copy(sharedMedia = null)
state.copy(sharedMedia = emptyList())
}
}
}
@@ -123,7 +108,6 @@ sealed class ConversationSettingsViewModel(
override fun onCleared() {
cleared = true
openedMediaCursors.forEach { it.ensureClosed() }
store.clear()
disposable.clear()
}
@@ -134,8 +118,6 @@ sealed class ConversationSettingsViewModel(
}
}
open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction")
private class RecipientSettingsViewModel(
private val recipientId: RecipientId,
private val callMessageIds: LongArray,
@@ -296,7 +278,7 @@ sealed class ConversationSettingsViewModel(
),
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
specificSettingsState = state.requireGroupSettingsState().copy(
legacyGroupState = getLegacyGroupState(recipient)
legacyGroupState = getLegacyGroupState()
)
)
}
@@ -405,14 +387,8 @@ sealed class ConversationSettingsViewModel(
}
}
private fun getLegacyGroupState(recipient: Recipient): LegacyGroupPreference.State {
val showLegacyInfo = recipient.requireGroupId().isV1
return if (showLegacyInfo && recipient.participantIds.size > FeatureFlags.groupLimits().hardLimit) {
LegacyGroupPreference.State.TOO_LARGE
} else if (showLegacyInfo) {
LegacyGroupPreference.State.UPGRADE
} else if (groupId.isMms) {
private fun getLegacyGroupState(): LegacyGroupPreference.State {
return if (groupId.isMms) {
LegacyGroupPreference.State.MMS_WARNING
} else {
LegacyGroupPreference.State.NONE
@@ -483,12 +459,6 @@ sealed class ConversationSettingsViewModel(
override fun unblock() {
repository.unblock(groupId)
}
override fun initiateGroupUpgrade() {
repository.getExternalPossiblyMigratedGroupRecipientId(groupId) {
internalEvents.onNext(ConversationSettingsEvent.InitiateGroupMigration(it))
}
}
}
class Factory(

View File

@@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import java.util.Locale
/**
* Renders a single call preference row when displaying call info.
@@ -82,7 +81,7 @@ object CallPreference {
}
private fun getCallTime(messageRecord: MessageRecord): String {
return DateUtils.getOnlyTimeString(context, Locale.getDefault(), messageRecord.timestamp)
return DateUtils.getOnlyTimeString(context, messageRecord.timestamp)
}
}
}

View File

@@ -4,7 +4,6 @@ import android.view.View
import androidx.core.content.ContextCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
@@ -19,7 +18,6 @@ object LegacyGroupPreference {
class Model(
val state: State,
val onLearnMoreClick: () -> Unit,
val onUpgradeClick: () -> Unit,
val onMmsWarningClick: () -> Unit
) : PreferenceModel<Model>() {
override fun areItemsTheSame(newItem: Model): Boolean {
@@ -42,15 +40,6 @@ object LegacyGroupPreference {
groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() }
groupInfoText.setLearnMoreVisible(true)
}
State.UPGRADE -> {
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade)
groupInfoText.setOnLinkClickListener { model.onUpgradeClick() }
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group)
}
State.TOO_LARGE -> {
groupInfoText.text = context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().hardLimit - 1)
groupInfoText.setLearnMoreVisible(false)
}
State.MMS_WARNING -> {
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group)
groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() }
@@ -63,8 +52,6 @@ object LegacyGroupPreference {
enum class State {
LEARN_MORE,
UPGRADE,
TOO_LARGE,
MMS_WARNING,
NONE
}

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.components.settings.conversation.preferences
import android.database.Cursor
import android.view.View
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ThreadPhotoRailView
@@ -22,7 +21,7 @@ object SharedMediaPreference {
}
class Model(
val mediaCursor: Cursor,
val mediaRecords: List<MediaTable.MediaRecord>,
val mediaIds: List<Long>,
val onMediaRecordClick: (View, MediaTable.MediaRecord, Boolean) -> Unit
) : PreferenceModel<Model>() {
@@ -41,7 +40,7 @@ object SharedMediaPreference {
private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view)
override fun bind(model: Model) {
rail.setCursor(GlideApp.with(rail), model.mediaCursor)
rail.setMediaRecords(GlideApp.with(rail), model.mediaRecords)
rail.setListener { v, m ->
model.onMediaRecordClick(v, m, ViewUtil.isLtr(rail))
}

View File

@@ -207,7 +207,11 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
private fun displayPendingGallery(currentState: TransferControlViewState) {
binding.primaryProgressView.startClickListener = currentState.downloadClickedListener
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView, binding.primaryDetailsText), listOf(binding.secondaryProgressView, binding.playVideoButton))
applyFocusableAndClickable(
currentState,
listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground),
listOf(binding.secondaryProgressView, binding.playVideoButton)
)
binding.primaryProgressView.setStopped(false)
showAllViews(
playVideoButton = false,
@@ -215,6 +219,9 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
secondaryDetailsText = currentState.showSecondaryText
)
binding.primaryDetailsText.setOnClickListener(currentState.downloadClickedListener)
binding.primaryBackground.setOnClickListener(currentState.downloadClickedListener)
binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
ViewUtil.dpToPx(-8).toFloat()
} else {
@@ -430,7 +437,10 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE }
}
activeViews.forEach { it.isClickable = currentState.isClickable }
inactiveViews.forEach { it.isClickable = false }
inactiveViews.forEach {
it.setOnClickListener(null)
it.isClickable = false
}
}
override fun setFocusable(focusable: Boolean) {

View File

@@ -100,7 +100,7 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
}
}
override fun isDarkTheme(): Boolean = true
override val forceDarkTheme = true
private val webRtcCallViewModel: WebRtcCallViewModel by activityViewModels()
private val callLinkDetailsViewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {

View File

@@ -6,7 +6,7 @@ import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
public enum WebRtcAudioOutput {
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_24),
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_28),
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.symbol_speaker_fill_white_24),
BLUETOOTH_HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.symbol_speaker_bluetooth_fill_white_24),
WIRED_HEADSET(R.string.WebRtcAudioOutputToggle__wired_headset, R.drawable.symbol_headphones_filed_24);

View File

@@ -15,7 +15,7 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -86,7 +86,7 @@ class WebRtcAudioOutputBottomSheet : ComposeBottomSheetDialogFragment(), DialogI
@Composable
fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDeviceId: Int, modifier: Modifier = Modifier.fillMaxWidth(), onDeviceSelected: (AudioOutputOption) -> Unit) {
var selectedDeviceId by rememberSaveable { mutableStateOf(initialDeviceId) }
var selectedDeviceId by rememberSaveable { mutableIntStateOf(initialDeviceId) }
Column(
horizontalAlignment = Alignment.Start,
modifier = modifier
@@ -94,6 +94,7 @@ fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDevi
Text(
text = stringResource(R.string.WebRtcAudioOutputToggle__audio_output),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(8.dp)
)
@@ -127,6 +128,7 @@ fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDevi
Text(
text = device.friendlyName,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp)
)
}

View File

@@ -86,21 +86,12 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
val currentOutput = outputState.getCurrentOutput()
val numberOfOutputs = outputState.getOutputs().size
val extra = if (numberOfOutputs < SHOW_PICKER_THRESHOLD) {
when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_on)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected) // should never be seen in practice.
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected) // should never be seen in practice.
}
} else {
when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_handset_selected)
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_selected)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
}
val shouldShowDropdownForSpeaker = outputState.getOutputs().size >= SHOW_PICKER_THRESHOLD || !outputState.getOutputs().contains(WebRtcAudioOutput.HANDSET)
val extra = when (currentOutput) {
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
WebRtcAudioOutput.SPEAKER -> if (shouldShowDropdownForSpeaker) intArrayOf(R.attr.state_speaker_selected) else intArrayOf(R.attr.state_speaker_on)
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
}
Log.i(TAG, "Switching to $currentOutput")

View File

@@ -66,7 +66,7 @@ class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() {
}
}
override fun isDarkTheme(): Boolean = true
override val forceDarkTheme = true
private val recipientId: RecipientId by lazy {
requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.unit.dp
import org.signal.core.ui.theme.SignalTheme
@@ -16,16 +17,16 @@ import org.thoughtcrime.securesms.util.DynamicTheme
abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
protected open fun isDarkTheme(): Boolean = DynamicTheme.isDarkTheme(requireContext())
protected open val forceDarkTheme = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
SignalTheme(
isDarkMode = isDarkTheme()
isDarkMode = forceDarkTheme || DynamicTheme.isDarkTheme(LocalContext.current)
) {
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
Surface(shape = RoundedCornerShape(18.dp, 18.dp), color = SignalTheme.colors.colorSurface1) {
SheetContent()
}
}

View File

@@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.compose
import android.animation.ValueAnimator
import android.app.Activity
import androidx.core.content.ContextCompat
import com.google.android.material.animation.ArgbEvaluatorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.WindowUtil
/**
* Controls status-bar color based off ability to scroll up
*/
class StatusBarColorAnimator(
private val activity: Activity
) {
private var animator: ValueAnimator? = null
private var previousCanScrollUp: Boolean = false
private val normalColor = ContextCompat.getColor(activity, R.color.signal_colorBackground)
private val scrollColor = ContextCompat.getColor(activity, R.color.signal_colorSurface2)
fun setCanScrollUp(canScrollUp: Boolean) {
if (previousCanScrollUp == canScrollUp) {
return
}
previousCanScrollUp = canScrollUp
applyState(canScrollUp)
}
fun setColorImmediate() {
val end = when {
previousCanScrollUp -> scrollColor
else -> normalColor
}
animator?.cancel()
WindowUtil.setStatusBarColor(
activity.window,
end
)
}
private fun applyState(canScrollUp: Boolean) {
val (start, end) = when {
canScrollUp -> normalColor to scrollColor
else -> scrollColor to normalColor
}
animator?.cancel()
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 200
addUpdateListener {
WindowUtil.setStatusBarColor(
activity.window,
ArgbEvaluatorCompat.getInstance().evaluate(it.animatedFraction, start, end)
)
}
start()
}
}
}

View File

@@ -73,7 +73,8 @@ object ContactDiscovery {
ContactDiscoveryRefreshV2.refreshAll(context, useCompat = FeatureFlags.cdsCompatMode())
},
removeSystemContactLinksIfMissing = true,
notifyOfNewUsers = notifyOfNewUsers
notifyOfNewUsers = notifyOfNewUsers,
forceFullSystemContactSync = true
)
StorageSyncHelper.scheduleSyncForDataChange()
@@ -140,7 +141,8 @@ object ContactDiscovery {
descriptor: String,
refresh: () -> RefreshResult,
removeSystemContactLinksIfMissing: Boolean,
notifyOfNewUsers: Boolean
notifyOfNewUsers: Boolean,
forceFullSystemContactSync: Boolean = false
): RefreshResult {
val stopwatch = Stopwatch(descriptor)
@@ -153,7 +155,7 @@ object ContactDiscovery {
if (hasContactsPermissions(context)) {
ApplicationDependencies.getJobManager().add(SyncSystemContactLinksJob())
val useFullSync = removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD
val useFullSync = forceFullSystemContactSync || (removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD)
syncRecipientsWithSystemContacts(
context = context,
rewrites = result.rewrites,

View File

@@ -206,9 +206,11 @@ object ContactDiscoveryRefreshV2 {
*/
@WorkerThread
private fun Set<RecipientId>.removePossiblyRegisteredButUnlisted(): Set<RecipientId> {
val selfId = Recipient.self().id
return this - Recipient.resolvedList(this)
.filter { it.hasServiceId() }
.filter { hasCommunicatedWith(it) }
.filter {
(it.hasServiceId() && hasCommunicatedWith(it)) || it.id == selfId
}
.map { it.id }
.toSet()
}

View File

@@ -46,7 +46,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
@@ -326,7 +326,7 @@ public class ConversationAdapter
if (conversationMessage == null) return -1;
if (displayMode.getScheduleMessageMode()) {
calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
calendar.setTimeInMillis(((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
} else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
} else {
@@ -346,7 +346,7 @@ public class ConversationAdapter
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
if (displayMode.getScheduleMessageMode()) {
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
} else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
} else {

View File

@@ -105,9 +105,8 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.MediaTable;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.PartProgressEvent;
@@ -1132,7 +1131,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
sharedContactStub.get().setContact(((MmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
sharedContactStub.get().setEventListener(sharedContactEventListener);
sharedContactStub.get().setOnClickListener(sharedContactClickListener);
sharedContactStub.get().setOnLongClickListener(passthroughClickListener);
@@ -1219,7 +1218,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
paymentViewStub.setVisibility(View.GONE);
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
audioViewStub.get().setAudio(Objects.requireNonNull(((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
@@ -1249,7 +1248,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
//noinspection ConstantConditions
documentViewStub.get().setDocument(
((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(),
((MmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(),
showControls,
displayMode != ConversationItemDisplayMode.Detailed.INSTANCE
);
@@ -1381,7 +1380,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
MmsMessageRecord mediaMmsMessageRecord = (MmsMessageRecord) messageRecord;
paymentViewStub.setVisibility(View.VISIBLE);
paymentViewStub.get().bindPayment(conversationRecipient.get(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
@@ -1592,9 +1591,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (quoteView == null) {
throw new AssertionError();
}
Quote quote = ((MediaMmsMessageRecord) current).getQuote();
Quote quote = ((MmsMessageRecord) current).getQuote();
if (((MediaMmsMessageRecord) current).getParentStoryId() != null) {
if (((MmsMessageRecord) current).getParentStoryId() != null) {
quoteView.setMessageType(current.isOutgoing() ? QuoteView.MessageType.STORY_REPLY_OUTGOING : QuoteView.MessageType.STORY_REPLY_INCOMING);
} else {
quoteView.setMessageType(current.isOutgoing() ? QuoteView.MessageType.OUTGOING : QuoteView.MessageType.INCOMING);
@@ -2244,7 +2243,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public @Nullable Projection getOpenableGiftProjection(boolean isAnimating) {
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || (messageRecord.getViewedReceiptCount() > 0 && !isAnimating)) {
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || (messageRecord.isViewed() && !isAnimating)) {
return null;
}

View File

@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.database.BodyRangeUtil;
import org.thoughtcrime.securesms.database.MentionUtil;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.Mention;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
@@ -161,7 +161,7 @@ public class ConversationMessage {
}
public static @NonNull FormattedDate getFormattedDate(@NonNull Context context, @NonNull MessageRecord messageRecord) {
return MessageRecordUtil.isScheduled(messageRecord) ? new FormattedDate(false, DateUtils.getOnlyTimeString(context, Locale.getDefault(), ((MediaMmsMessageRecord) messageRecord).getScheduledDate()))
return MessageRecordUtil.isScheduled(messageRecord) ? new FormattedDate(false, DateUtils.getOnlyTimeString(context, ((MmsMessageRecord) messageRecord).getScheduledDate()))
: DateUtils.getDatelessRelativeTimeSpanFormattedDate(context, Locale.getDefault(), messageRecord.getTimestamp());
}

View File

@@ -16,7 +16,6 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
@@ -119,14 +118,6 @@ internal object ConversationOptionsMenu {
}
}
menuInflater.inflate(R.menu.conversation_group_options, menu)
if (!recipient.isPushGroup) {
menuInflater.inflate(R.menu.conversation_mms_group_options, menu)
if (distributionType == ThreadTable.DistributionTypes.BROADCAST) {
menu.findItem(R.id.menu_distribution_broadcast).isChecked = true
} else {
menu.findItem(R.id.menu_distribution_conversation).isChecked = true
}
}
menuInflater.inflate(R.menu.conversation_active_group_options, menu)
}
@@ -216,8 +207,6 @@ internal object ConversationOptionsMenu {
R.id.menu_search -> callback.handleSearch()
R.id.menu_add_to_contacts -> callback.handleAddToContacts()
R.id.menu_group_recipients -> callback.handleDisplayGroupRecipients()
R.id.menu_distribution_broadcast -> callback.handleDistributionBroadcastEnabled(menuItem)
R.id.menu_distribution_conversation -> callback.handleDistributionConversationEnabled(menuItem)
R.id.menu_group_settings -> callback.handleManageGroup()
R.id.menu_leave -> callback.handleLeavePushGroup()
R.id.menu_invite -> callback.handleInviteLink()
@@ -283,8 +272,6 @@ internal object ConversationOptionsMenu {
fun handleSearch()
fun handleAddToContacts()
fun handleDisplayGroupRecipients()
fun handleDistributionBroadcastEnabled(menuItem: MenuItem)
fun handleDistributionConversationEnabled(menuItem: MenuItem)
fun handleManageGroup()
fun handleLeavePushGroup()
fun handleInviteLink()

View File

@@ -41,7 +41,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -716,7 +715,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
if (FeatureFlags.editMessageSending() && menuState.shouldShowEditAction()) {
if (menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
}

View File

@@ -3,9 +3,8 @@ package org.thoughtcrime.securesms.conversation;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
@@ -167,15 +166,15 @@ public final class MenuState {
MessageRecord messageRecord = multiSelectRecord.getMessageRecord();
builder.shouldShowResendAction(messageRecord.isFailed())
.shouldShowSaveAttachmentAction(mediaIsSelected &&
!actionMessage &&
!viewOnce &&
messageRecord.isMms() &&
!hasPendingMedia &&
!hasGift &&
!messageRecord.isMmsNotification() &&
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
.shouldShowSaveAttachmentAction(mediaIsSelected &&
!actionMessage &&
!viewOnce &&
messageRecord.isMms() &&
!hasPendingMedia &&
!hasGift &&
!messageRecord.isMmsNotification() &&
((MmsMessageRecord)messageRecord).containsMediaSlide() &&
((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));

View File

@@ -10,7 +10,6 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.util.Locale
class ScheduleMessageContextMenu {
@@ -24,7 +23,7 @@ class ScheduleMessageContextMenu {
val scheduledTimes = getNextScheduleTimes(currentTime)
val actionItems = scheduledTimes.map {
if (it > 0) {
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, it)) {
action(it)
}
} else {

View File

@@ -61,6 +61,10 @@ class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialog
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
}
if (scheduledLocalDateTime.isBefore(LocalDateTime.now())) {
scheduledLocalDateTime.plusDays(1)
}
scheduledHour = scheduledLocalDateTime.hour
scheduledMinute = scheduledLocalDateTime.minute

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