mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-14 22:13:19 +01:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a2482c52d | ||
|
|
a715192844 | ||
|
|
7dc4661fb1 | ||
|
|
e486a4baef | ||
|
|
5fc11baf9e | ||
|
|
157777cac1 | ||
|
|
99d0ee6725 | ||
|
|
b5c1051506 | ||
|
|
bba3334df5 | ||
|
|
74488feec2 | ||
|
|
54953abc67 | ||
|
|
117bbdbcdf | ||
|
|
b96b99c1c4 | ||
|
|
6e856a7648 | ||
|
|
0659edb762 | ||
|
|
dcb870c432 | ||
|
|
772bafbe43 | ||
|
|
a9be6aff44 | ||
|
|
dcd7ec7383 | ||
|
|
c69a4dda00 | ||
|
|
a911926119 | ||
|
|
6f30aec4f2 | ||
|
|
5a005fb809 | ||
|
|
776a4c5dce | ||
|
|
c53c316303 | ||
|
|
622aa844e4 | ||
|
|
de2cf6026e | ||
|
|
a8e02b9ced | ||
|
|
297308ad76 | ||
|
|
ea0c3dbe5a | ||
|
|
b8d229e58e | ||
|
|
c4f5110148 | ||
|
|
7fdd7e89bd | ||
|
|
2378346537 | ||
|
|
72fc5fc3b1 | ||
|
|
c063c99ba6 | ||
|
|
90341f0a6e | ||
|
|
cdb9df5aba | ||
|
|
1f6d9d6422 | ||
|
|
ffbda7e521 | ||
|
|
3b5ef29047 | ||
|
|
14cf6ceb84 | ||
|
|
5fb940ff2a | ||
|
|
f446e18289 | ||
|
|
84f26b32d6 | ||
|
|
f7690245aa | ||
|
|
f44e32fd6a | ||
|
|
8bac34238e | ||
|
|
6d2f6ce2f9 | ||
|
|
3a465cc56b | ||
|
|
617369dbc0 | ||
|
|
c0fed1498e | ||
|
|
5bdd3ce47a | ||
|
|
6b3f41d675 | ||
|
|
23b696c9cf | ||
|
|
079400f89e | ||
|
|
e12d467627 | ||
|
|
162ca3e21e | ||
|
|
dddd0e7b71 | ||
|
|
95d68e09da | ||
|
|
aaf0cf53d8 | ||
|
|
9c8f759732 | ||
|
|
a45c685893 | ||
|
|
87bdebb21c | ||
|
|
4f754ae309 | ||
|
|
4b004f70ec | ||
|
|
d468d4c21b | ||
|
|
a4df433d80 | ||
|
|
10eec025d2 | ||
|
|
d497ed4195 | ||
|
|
e63137d293 | ||
|
|
c744743913 | ||
|
|
42493c8eb6 | ||
|
|
391839028f | ||
|
|
d9ecfeadc0 | ||
|
|
d866646f66 | ||
|
|
6295041341 | ||
|
|
8c7556427a | ||
|
|
82c91db78c | ||
|
|
2d969f4fff | ||
|
|
e84d46dae7 | ||
|
|
b6828b54ca | ||
|
|
f9bd1bac36 | ||
|
|
22e2bfacae | ||
|
|
c446d4bb54 | ||
|
|
23c7e5dc3f | ||
|
|
661f1e624c | ||
|
|
81ff5ef899 | ||
|
|
e79364cb03 | ||
|
|
d750e2fe7a | ||
|
|
5e1025453a | ||
|
|
280da481ee | ||
|
|
9da5f47623 | ||
|
|
45f1f419e1 | ||
|
|
92f2ac67d5 | ||
|
|
d28a62d70b | ||
|
|
f9336f2a28 | ||
|
|
940e67b1ca | ||
|
|
073e138ab2 | ||
|
|
5aec4b4571 | ||
|
|
f9cd3decb1 | ||
|
|
627c47b155 | ||
|
|
57135ea2c6 | ||
|
|
609e9fcdb0 | ||
|
|
5b0e71b680 | ||
|
|
9c2d478797 | ||
|
|
c55fa13038 | ||
|
|
27b9565d2f | ||
|
|
4fe6d79fff | ||
|
|
e636e38ba1 | ||
|
|
ebc6665224 | ||
|
|
7001cedbc7 | ||
|
|
b14209d5cf | ||
|
|
5150564fe2 | ||
|
|
b7eaa9e353 | ||
|
|
c00943591d | ||
|
|
1f9320200a | ||
|
|
6a6b80cce2 | ||
|
|
05296e3d9b | ||
|
|
7e68050e0a | ||
|
|
ab928be1b3 | ||
|
|
65d26d753d | ||
|
|
bf37c09ba0 | ||
|
|
89199b81ab | ||
|
|
0dd17673f5 | ||
|
|
c17d6c2334 | ||
|
|
5285dd1665 | ||
|
|
046ce30e08 | ||
|
|
1601fa5608 | ||
|
|
5f7099184d | ||
|
|
8425bb4f59 | ||
|
|
e44006f531 | ||
|
|
3423e24de6 | ||
|
|
5ac363232f | ||
|
|
9cc020a2c7 | ||
|
|
d2240f07d8 | ||
|
|
4968db750b | ||
|
|
6134244244 | ||
|
|
4559ca9f2b | ||
|
|
9a38920cb8 | ||
|
|
2b771931e6 | ||
|
|
d72e003f8c |
@@ -25,7 +25,7 @@ wire {
|
||||
}
|
||||
|
||||
protoPath {
|
||||
srcDir "${project.rootDir}/libsignal/service/src/main/protowire"
|
||||
srcDir "${project.rootDir}/libsignal-service/src/main/protowire"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1346
|
||||
def canonicalVersionName = "6.36.4"
|
||||
def canonicalVersionCode = 1360
|
||||
def canonicalVersionName = "6.39.3"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -185,7 +185,6 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDN3_IPS", cdn3_ips
|
||||
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String[]", "SIGNAL_CDSI_IPS", cdsi_ips
|
||||
@@ -193,12 +192,6 @@ android {
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"0f6fd79cdfdaa5b2e6337f534d3baf999318b0c462a7ac1f41297a3e4b424a57\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"6ee1042f9e20f880326686dd4ba50c25359f01e9f733eeba4382bca001d45094\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
|
||||
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AByD873dTilmOSG0TjKrvpeaKEsUmIO8Vx9BeMmftwUs9v7ikPwM8P3OHyT0+X3EUMZrSe9VUp26Wai51Q9I8mdk0hX/yo7CeFGJyzoOqn8e/i4Ygbn5HoAyXJx5eXfIbqpc0bIxzju4H/HOQeOpt6h742qii5u/cbwOhFZCsMIbElZTaeU+BWMBQiZHIGHT5IE0qCordQKZ5iPZom0HeFa8Yq0ShuEyAl0WINBiY6xE3H/9WnvzXBbMuuk//eRxXgzO8ieCeK8FwQNxbfXqZm6Ro1cMhCOF3u7xoX83QhpN\""
|
||||
@@ -326,26 +319,27 @@ android {
|
||||
play {
|
||||
dimension 'distribution'
|
||||
isDefault true
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "null"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
|
||||
}
|
||||
|
||||
website {
|
||||
dimension 'distribution'
|
||||
ext.websiteUpdateUrl = "https://updates.signal.org/android"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
|
||||
}
|
||||
|
||||
nightly {
|
||||
def apkUpdateManifestUrl = "<unset>"
|
||||
if (project.hasProperty('nightlyApkUpdateManifestUrl')) {
|
||||
apkUpdateManifestUrl = project.getProperty('nightlyApkUpdateManifestUrl')
|
||||
}
|
||||
dimension 'distribution'
|
||||
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||
ext.websiteUpdateUrl = "null"
|
||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||
}
|
||||
|
||||
@@ -372,12 +366,6 @@ android {
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SVR2_URL", "\"https://svr2.staging.signal.org\""
|
||||
buildConfigField "String", "SVR2_MRENCLAVE", "\"a8a261420a6bb9b61aa25bf8a79e8bd20d7652531feb3381cbffd446d270be95\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
|
||||
"\"ee1d0d972b7ea903615670de43ab1b6e7a825e811c70a29bb5fe0f819e0975fa\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "GENERIC_SERVER_PUBLIC_PARAMS", "\"AHILOIrFPXX9laLbalbA9+L1CXpSbM/bTJXZGZiuyK1JaI6dK5FHHWL6tWxmHKYAZTSYmElmJ5z2A5YcirjO/yfoemE03FItyaf8W1fE4p14hzb5qnrmfXUSiAIVrhaXVwIwSzH6RL/+EO8jFIjJ/YfExfJ8aBl48CKHgu1+A6kWynhttonvWWx6h7924mIzW0Czj2ROuh4LwQyZypex4GuOPW8sgIT21KNZaafgg+KbV7XM1x1tF3XA17B4uGUaDbDw2O+nR1+U5p6qHPzmJ7ggFjSN6Utu+35dS1sS0P9N\""
|
||||
@@ -416,6 +404,9 @@ android {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
output.versionNameOverride = tag
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk")
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
}
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
@@ -675,6 +666,23 @@ tasks.withType(Test) {
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.configureEach { task ->
|
||||
if (task.name.toLowerCase().contains("nightly") && task.name != 'checkNightlyParams') {
|
||||
task.dependsOn checkNightlyParams
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('checkNightlyParams') {
|
||||
doFirst {
|
||||
if (project.gradle.startParameter.taskNames.any { it.toLowerCase().contains("nightly") }) {
|
||||
if (!project.hasProperty('nightlyApkUpdateManifestUrl')) {
|
||||
throw new GradleException("Required command-line parameter 'nightlyApkUpdateManifestUrl' not found for nightly build!")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def loadKeystoreProperties(filename) {
|
||||
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
-keep class org.whispersystems.** { *; }
|
||||
-keep class org.signal.libsignal.protocol.** { *; }
|
||||
-keep class org.thoughtcrime.securesms.** { *; }
|
||||
-keep class org.signal.donations.json.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(**);
|
||||
}
|
||||
|
||||
@@ -230,8 +230,6 @@ class ChangeNumberViewModelTest {
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
MockProvider.mockGetRegistrationLockStringFlow()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
@@ -318,8 +316,6 @@ class ChangeNumberViewModelTest {
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
MockProvider.mockGetRegistrationLockStringFlow()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Post("/v1/verification/session") { MockResponse().success(MockProvider.sessionMetadataJson.copy(verified = false)) },
|
||||
Put("/v1/verification/session/${MockProvider.sessionMetadataJson.id}/code") { MockResponse().success(MockProvider.sessionMetadataJson) },
|
||||
|
||||
@@ -9,8 +9,9 @@ import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -64,7 +65,8 @@ class ConversationItemPreviewer {
|
||||
attachment()
|
||||
}
|
||||
|
||||
val message = IncomingMediaMessage(
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
@@ -73,7 +75,7 @@ class ConversationItemPreviewer {
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments))
|
||||
)
|
||||
|
||||
SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
@@ -83,7 +85,8 @@ class ConversationItemPreviewer {
|
||||
attachment()
|
||||
}
|
||||
|
||||
val message = IncomingMediaMessage(
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
@@ -92,7 +95,7 @@ class ConversationItemPreviewer {
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments))
|
||||
)
|
||||
|
||||
val insert = SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
val insert = SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(insert.messageId).forEachIndexed { index, attachment ->
|
||||
// if (index != 1) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.database
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Optional
|
||||
@@ -55,9 +55,9 @@ object MmsHelper {
|
||||
}
|
||||
|
||||
fun insert(
|
||||
message: IncomingMediaMessage,
|
||||
message: IncomingMessage,
|
||||
threadId: Long
|
||||
): Optional<MessageTable.InsertResult> {
|
||||
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, threadId)
|
||||
return SignalDatabase.messages.insertMessageInbox(message, threadId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
@@ -73,7 +73,8 @@ class MmsTableTest_stories {
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
@@ -95,7 +96,8 @@ class MmsTableTest_stories {
|
||||
// GIVEN
|
||||
val sender = recipients[0]
|
||||
val messageId = MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
@@ -122,7 +124,8 @@ class MmsTableTest_stories {
|
||||
// GIVEN
|
||||
val messageIds = recipients.take(5).map {
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = it,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
@@ -154,7 +157,8 @@ class MmsTableTest_stories {
|
||||
val unviewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
@@ -168,7 +172,8 @@ class MmsTableTest_stories {
|
||||
val viewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
@@ -213,7 +218,8 @@ class MmsTableTest_stories {
|
||||
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
|
||||
// GIVEN
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = recipients[0],
|
||||
sentTimeMillis = 200,
|
||||
serverTimeMillis = 2,
|
||||
@@ -321,7 +327,8 @@ class MmsTableTest_stories {
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = myStory.id,
|
||||
sentTimeMillis = 201,
|
||||
serverTimeMillis = 201,
|
||||
|
||||
@@ -14,8 +14,10 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.select
|
||||
@@ -34,12 +36,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
@@ -142,6 +142,30 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
process(null, null, null)
|
||||
}
|
||||
|
||||
test("pni matches, pni+aci provided, no pni session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(null, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("pni matches, pni+aci provided, pni session") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
process(null, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectSessionSwitchoverEvent(E164_A)
|
||||
}
|
||||
|
||||
test("pni matches, pni+aci provided, pni session, pni-verified") {
|
||||
given(E164_A, PNI_A, null, pniSession = true)
|
||||
process(null, PNI_A, ACI_A, pniVerified = true)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("no match, all fields") {
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
@@ -801,9 +825,9 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
val smsId2: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
val smsId3: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
||||
|
||||
val mmsId1: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
val mmsId1: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = SignalDatabase.messages.insertMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
|
||||
val threadIdAci: Long = SignalDatabase.threads.getThreadIdFor(recipientIdAci)!!
|
||||
val threadIdE164: Long = SignalDatabase.threads.getThreadIdFor(recipientIdE164)!!
|
||||
@@ -923,12 +947,30 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
|
||||
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
|
||||
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMessage {
|
||||
return IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = sender,
|
||||
sentTimeMillis = time,
|
||||
serverTimeMillis = time,
|
||||
receivedTimeMillis = time,
|
||||
body = body,
|
||||
groupId = groupId.orNull(),
|
||||
isUnidentified = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
|
||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty(), false, false)
|
||||
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMessage {
|
||||
return IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = sender,
|
||||
groupId = groupId.orNull(),
|
||||
body = body,
|
||||
sentTimeMillis = time,
|
||||
receivedTimeMillis = time,
|
||||
serverTimeMillis = time,
|
||||
isUnidentified = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
|
||||
@@ -18,12 +18,10 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.groupChange
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.groupContext
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName", "TestFunctionName")
|
||||
@@ -272,13 +270,28 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
assertThat("latest message should be deleted", sms.getMessageRecordOrNull(latestMessage.messageId), nullValue())
|
||||
}
|
||||
|
||||
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingTextMessage {
|
||||
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingMessage {
|
||||
wallClock++
|
||||
return IncomingTextMessage(sender, 1, wallClock, wallClock, wallClock, body, Optional.of(groupId), 0, true, null)
|
||||
return IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = sender,
|
||||
sentTimeMillis = wallClock,
|
||||
serverTimeMillis = wallClock,
|
||||
receivedTimeMillis = wallClock,
|
||||
body = body,
|
||||
groupId = groupId,
|
||||
isUnidentified = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingGroupUpdateMessage {
|
||||
return IncomingGroupUpdateMessage(smsMessage(sender, null), groupContext)
|
||||
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingMessage {
|
||||
wallClock++
|
||||
return IncomingMessage.groupUpdate(
|
||||
from = sender,
|
||||
timestamp = wallClock,
|
||||
groupId = groupId,
|
||||
groupContext = groupContext
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -13,9 +13,9 @@ import okio.ByteString
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.KbsEnclave
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipientCache
|
||||
@@ -23,32 +23,25 @@ import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Verb
|
||||
import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
|
||||
import java.security.KeyStore
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Dependency provider used for instrumentation tests (aka androidTests).
|
||||
*
|
||||
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess] and
|
||||
* [KeyBackupService].
|
||||
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess].
|
||||
*/
|
||||
class InstrumentationApplicationDependencyProvider(application: Application, default: ApplicationDependencyProvider) : ApplicationDependencies.Provider by default {
|
||||
|
||||
private val serviceTrustStore: TrustStore
|
||||
private val uncensoredConfiguration: SignalServiceConfiguration
|
||||
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
|
||||
private val keyBackupService: KeyBackupService
|
||||
private val recipientCache: LiveRecipientCache
|
||||
|
||||
init {
|
||||
@@ -80,7 +73,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
|
||||
),
|
||||
signalKeyBackupServiceUrls = arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalStorageUrls = arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalCdsiUrls = arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
signalSvr2Urls = arrayOf(SignalSvr2Url(baseUrl, serviceTrustStore, "localhost", ConnectionSpec.CLEARTEXT)),
|
||||
@@ -97,8 +89,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
on { uncensoredConfiguration } doReturn uncensoredConfiguration
|
||||
}
|
||||
|
||||
keyBackupService = mock()
|
||||
|
||||
recipientCache = LiveRecipientCache(application) { r -> r.run() }
|
||||
}
|
||||
|
||||
@@ -106,10 +96,6 @@ class InstrumentationApplicationDependencyProvider(application: Application, def
|
||||
return serviceNetworkAccessMock
|
||||
}
|
||||
|
||||
override fun provideKeyBackupService(signalServiceAccountManager: SignalServiceAccountManager, keyStore: KeyStore, enclave: KbsEnclave): KeyBackupService {
|
||||
return keyBackupService
|
||||
}
|
||||
|
||||
override fun provideRecipientCache(): LiveRecipientCache {
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
@@ -20,7 +21,6 @@ import org.thoughtcrime.securesms.testing.failure
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import org.whispersystems.util.Base64UrlSafe
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -58,13 +58,13 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(serverUsername))
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(serverUsername))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
@@ -94,7 +94,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
@@ -122,13 +122,13 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(username))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64UrlSafe.encodeBytesWithoutPadding(Username.hash(username))))
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
@@ -156,7 +156,7 @@ class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64UrlSafe.encodeBytesWithoutPadding(Username.hash("${username}23"))
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash("${username}23"))
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -6,12 +6,12 @@ import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.update
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.FeatureFlagsAccessor
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
@@ -113,7 +113,7 @@ class ContactRecordProcessorTest {
|
||||
private fun setStorageId(recipientId: RecipientId, storageId: StorageId) {
|
||||
SignalDatabase.rawDatabase
|
||||
.update(RecipientTable.TABLE_NAME)
|
||||
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeBytes(storageId.raw))
|
||||
.values(RecipientTable.STORAGE_SERVICE_ID to Base64.encodeWithPadding(storageId.raw))
|
||||
.where("${RecipientTable.ID} = ?", recipientId)
|
||||
.run()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.internal.Native
|
||||
import org.signal.libsignal.internal.NativeHandleGuard
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator
|
||||
@@ -20,7 +21,6 @@ import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.util.Base64
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.stub
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.protocol.util.Medium
|
||||
import org.signal.libsignal.svr2.PinHash
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.test.BuildConfig
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.SvrPinData
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
|
||||
@@ -78,18 +68,6 @@ object MockProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun mockGetRegistrationLockStringFlow() {
|
||||
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
|
||||
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
|
||||
override fun restorePin(hashedPin: PinHash?): SvrPinData = SvrPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
}
|
||||
|
||||
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
|
||||
kbsService.stub {
|
||||
on { newRegistrationSession(anyOrNull(), anyOrNull()) } doReturn session
|
||||
}
|
||||
}
|
||||
|
||||
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account().aciIdentityKey, deviceId: Int): PreKeyResponse {
|
||||
val signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), identity.privateKey)
|
||||
val oneTimePreKey = PreKeyRecord(SecureRandom().nextInt(Medium.MAX_VALUE), Curve.generateKeyPair())
|
||||
|
||||
@@ -38,10 +38,8 @@ object MessageTableTestUtils {
|
||||
isKeyExchangeType:${type and MessageTypes.KEY_EXCHANGE_BIT != 0L}
|
||||
isIdentityVerified:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT != 0L}
|
||||
isIdentityDefault:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT != 0L}
|
||||
isCorruptedKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CORRUPTED_BIT != 0L}
|
||||
isInvalidVersionKeyExchange:${type and MessageTypes.KEY_EXCHANGE_INVALID_VERSION_BIT != 0L}
|
||||
isBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_BUNDLE_BIT != 0L}
|
||||
isContentBundleKeyExchange:${type and MessageTypes.KEY_EXCHANGE_CONTENT_FORMAT != 0L}
|
||||
isIdentityUpdate:${type and MessageTypes.KEY_EXCHANGE_IDENTITY_UPDATE_BIT != 0L}
|
||||
isRateLimited:${type and MessageTypes.MESSAGE_RATE_LIMITED_BIT != 0L}
|
||||
isExpirationTimerUpdate:${type and MessageTypes.EXPIRATION_TIMER_UPDATE_BIT != 0L}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1189,6 +1189,10 @@
|
||||
android:name=".service.GenericForegroundService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".service.AttachmentProgressService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".gcm.FcmFetchBackgroundService"
|
||||
android:exported="false"/>
|
||||
@@ -1203,18 +1207,6 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name=".service.SmsListener"
|
||||
android:permission="android.permission.BROADCAST_SMS"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter android:priority="1001">
|
||||
<action android:name="android.provider.Telephony.SMS_RECEIVED"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.provider.Telephony.SMS_DELIVER"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.SmsDeliveryListener"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
@@ -1222,20 +1214,6 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.MmsListener"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BROADCAST_WAP_PUSH">
|
||||
<intent-filter android:priority="1001">
|
||||
<action android:name="android.provider.Telephony.WAP_PUSH_RECEIVED"/>
|
||||
<data android:mimeType="application/vnd.wap.mms-message" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER"/>
|
||||
<data android:mimeType="application/vnd.wap.mms-message" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".notifications.MarkReadReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ import org.conscrypt.Conscrypt;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.MemoryTracker;
|
||||
import org.signal.core.util.concurrent.AnrDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -52,6 +53,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
@@ -83,7 +85,7 @@ import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.service.LocalBackupListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateRefreshListener;
|
||||
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
@@ -108,6 +110,7 @@ import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import kotlin.Unit;
|
||||
import rxdogtag2.RxDogTag;
|
||||
|
||||
/**
|
||||
@@ -150,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("anr-detector", this::startAnrDetector)
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
@@ -224,7 +228,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
ApplicationDependencies.getDeadlockDetector().start();
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
ExternalLaunchDonationJob.enqueueIfNecessary();
|
||||
FcmFetchManager.onForeground(this);
|
||||
startAnrDetector();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
@@ -258,6 +264,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getShakeToReport().disable();
|
||||
ApplicationDependencies.getDeadlockDetector().stop();
|
||||
MemoryTracker.stop();
|
||||
AnrDetector.stop();
|
||||
}
|
||||
|
||||
public void checkBuildExpiration() {
|
||||
@@ -267,6 +274,17 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: this is purposefully "started" twice -- once during application create, and once during foreground.
|
||||
* This is so we can capture ANR's that happen on boot before the foreground event.
|
||||
*/
|
||||
private void startAnrDetector() {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), FeatureFlags::internalUser, (dumps) -> {
|
||||
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeSecurityProvider() {
|
||||
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
|
||||
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
|
||||
@@ -397,8 +415,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
RotateSenderCertificateListener.schedule(this);
|
||||
RoutineMessageFetchReceiver.startOrUpdateAlarm(this);
|
||||
|
||||
if (BuildConfig.PLAY_STORE_DISABLED) {
|
||||
UpdateApkRefreshListener.schedule(this);
|
||||
if (BuildConfig.MANAGES_APP_UPDATES) {
|
||||
ApkUpdateRefreshListener.schedule(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -145,6 +145,8 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean canSelectSelf;
|
||||
private boolean resetPositionOnCommit = false;
|
||||
|
||||
private ListClickListener listClickListener = new ListClickListener();
|
||||
@Nullable private SwipeRefreshLayout.OnRefreshListener onRefreshListener;
|
||||
|
||||
@@ -423,6 +425,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
onRefreshListener = null;
|
||||
}
|
||||
|
||||
public int getSelectedMembersSize() {
|
||||
return contactSearchMediator.getSelectedMembersSize();
|
||||
}
|
||||
|
||||
private @NonNull Bundle safeArguments() {
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
@@ -523,12 +529,17 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cursorFilter = filter;
|
||||
this.resetPositionOnCommit = true;
|
||||
this.cursorFilter = filter;
|
||||
|
||||
contactSearchMediator.onFilterChanged(filter);
|
||||
}
|
||||
|
||||
public void resetQueryFilter() {
|
||||
setQueryFilter(null);
|
||||
|
||||
this.resetPositionOnCommit = true;
|
||||
|
||||
swipeRefresh.setRefreshing(false);
|
||||
}
|
||||
|
||||
@@ -547,6 +558,11 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
private void onLoadFinished(int count) {
|
||||
if (resetPositionOnCommit) {
|
||||
resetPositionOnCommit = false;
|
||||
recyclerView.scrollToPosition(0);
|
||||
}
|
||||
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
showContactsLayout.setVisibility(View.GONE);
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Provided to the DownloadManager as a callback receiver for when it has finished downloading the APK we're trying to install.
|
||||
*
|
||||
* Registered in the manifest to list to [DownloadManager.ACTION_DOWNLOAD_COMPLETE].
|
||||
*/
|
||||
class ApkUpdateDownloadManagerReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ApkUpdateDownloadManagerReceiver::class.java)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.i(TAG, "onReceive()")
|
||||
|
||||
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE != intent.action) {
|
||||
Log.i(TAG, "Unexpected action: " + intent.action)
|
||||
return
|
||||
}
|
||||
|
||||
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -2)
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
Log.w(TAG, "downloadId doesn't match the one we're waiting for! Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.getDownloadManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
object ApkUpdateInstaller {
|
||||
|
||||
private val TAG = Log.tag(ApkUpdateInstaller::class.java)
|
||||
|
||||
/**
|
||||
* Installs the downloaded APK silently if possible. If not, prompts the user with a notification to install.
|
||||
* May show errors instead under certain conditions.
|
||||
*
|
||||
* A common pattern you may see is that this is called with [userInitiated] = false (or some other state
|
||||
* that prevents us from auto-updating, like the app being in the foreground), causing this function
|
||||
* to show an install prompt notification. The user clicks that notification, calling this with
|
||||
* [userInitiated] = true, and then everything installs.
|
||||
*/
|
||||
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
val digest = SignalStore.apkUpdate().digest
|
||||
if (digest == null) {
|
||||
Log.w(TAG, "DownloadId matches, but digest is null! Inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMatchingDigest(context, downloadId, digest)) {
|
||||
Log.w(TAG, "DownloadId matches, but digest does not! Bad download or inconsistent state. Failing and clearing state.")
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
return
|
||||
}
|
||||
|
||||
if (!userInitiated && !shouldAutoUpdate()) {
|
||||
Log.w(TAG, "Not user-initiated and not eligible for auto-update. Prompting. (API=${Build.VERSION.SDK_INT}, Foreground=${ApplicationDependencies.getAppForegroundObserver().isForegrounded}, AutoUpdate=${SignalStore.apkUpdate().autoUpdate})")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
installApk(context, downloadId, userInitiated)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Hit IOException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Hit SecurityException when trying to install APK!", e)
|
||||
SignalStore.apkUpdate().clearDownloadAttributes()
|
||||
ApkUpdateNotifications.showInstallFailed(context, ApkUpdateNotifications.FailureReason.UNKNOWN)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class, SecurityException::class)
|
||||
private fun installApk(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
val apkInputStream: InputStream? = getDownloadedApkInputStream(context, downloadId)
|
||||
if (apkInputStream == null) {
|
||||
Log.w(TAG, "Could not open download APK input stream!")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Beginning APK install...")
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
|
||||
// This lets us skip the system-generated notification.
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(TAG, "Creating install session...")
|
||||
val sessionId: Int = packageInstaller.createSession(sessionParams)
|
||||
val session: PackageInstaller.Session = packageInstaller.openSession(sessionId)
|
||||
|
||||
Log.d(TAG, "Writing APK data...")
|
||||
session.use { activeSession ->
|
||||
val sessionOutputStream = activeSession.openWrite(context.packageName, 0, -1)
|
||||
StreamUtil.copy(apkInputStream, sessionOutputStream)
|
||||
}
|
||||
|
||||
val installerPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
sessionId,
|
||||
Intent(context, ApkUpdatePackageInstallerReceiver::class.java).apply {
|
||||
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_USER_INITIATED, userInitiated)
|
||||
putExtra(ApkUpdatePackageInstallerReceiver.EXTRA_DOWNLOAD_ID, downloadId)
|
||||
},
|
||||
PendingIntentFlags.mutable() or PendingIntentFlags.updateCurrent()
|
||||
)
|
||||
|
||||
Log.d(TAG, "Committing session...")
|
||||
session.commit(installerPendingIntent.intentSender)
|
||||
}
|
||||
|
||||
private fun getDownloadedApkInputStream(context: Context, downloadId: Long): InputStream? {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMatchingDigest(context: Context, downloadId: Long, expectedDigest: ByteArray): Boolean {
|
||||
return try {
|
||||
FileInputStream(context.getDownloadManager().openDownloadedFile(downloadId).fileDescriptor).use { stream ->
|
||||
val digest = FileUtils.getFileDigest(stream)
|
||||
MessageDigest.isEqual(digest, expectedDigest)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldAutoUpdate(): Boolean {
|
||||
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
|
||||
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
/**
|
||||
* Receiver that is triggered based on various notification actions that can be taken on update-related notifications.
|
||||
*/
|
||||
class ApkUpdateNotificationReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ApkUpdateNotificationReceiver::class.java)
|
||||
|
||||
const val ACTION_INITIATE_INSTALL = "signal.apk_update_notification.initiate_install"
|
||||
const val EXTRA_DOWNLOAD_ID = "signal.download_id"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
if (intent == null) {
|
||||
Log.w(TAG, "Null intent")
|
||||
return
|
||||
}
|
||||
|
||||
val downloadId: Long = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2)
|
||||
|
||||
when (val action: String? = intent.action) {
|
||||
ACTION_INITIATE_INSTALL -> handleInstall(context, downloadId)
|
||||
else -> Log.w(TAG, "Unrecognized notification action: $action")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInstall(context: Context, downloadId: Long) {
|
||||
Log.i(TAG, "Got action to install.")
|
||||
ApkUpdateInstaller.installOrPromptForInstall(context, downloadId, userInitiated = true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.signal.core.util.PendingIntentFlags
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.notifications.NotificationChannels
|
||||
import org.thoughtcrime.securesms.notifications.NotificationIds
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
object ApkUpdateNotifications {
|
||||
|
||||
val TAG = Log.tag(ApkUpdateNotifications::class.java)
|
||||
|
||||
/**
|
||||
* Shows a notification to prompt the user to install the app update. Only shown when silently auto-updating is not possible or are disabled by the user.
|
||||
* Note: This is an 'ongoing' notification (i.e. not-user dismissable) and never dismissed programatically. This is because the act of installing the APK
|
||||
* will dismiss it for us.
|
||||
*/
|
||||
@SuppressLint("LaunchActivityFromNotification")
|
||||
fun showInstallPrompt(context: Context, downloadId: Long) {
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
1,
|
||||
Intent(context, ApkUpdateNotificationReceiver::class.java).apply {
|
||||
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
|
||||
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
|
||||
},
|
||||
PendingIntentFlags.immutable()
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
.setOngoing(true)
|
||||
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_prompt_install_title))
|
||||
.setContentText(context.getString(R.string.ApkUpdateNotifications_prompt_install_body))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun showInstallFailed(context: Context, reason: FailureReason) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntentFlags.immutable()
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_failed_general_title))
|
||||
.setContentText(context.getString(R.string.ApkUpdateNotifications_failed_general_body))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun showAutoUpdateSuccess(context: Context) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntentFlags.immutable()
|
||||
)
|
||||
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_auto_update_success_title))
|
||||
.setContentText(context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_SUCCESSFUL_INSTALL, notification)
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
UNKNOWN,
|
||||
ABORTED,
|
||||
BLOCKED,
|
||||
INCOMPATIBLE,
|
||||
INVALID,
|
||||
CONFLICT,
|
||||
STORAGE,
|
||||
TIMEOUT
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateNotifications.FailureReason
|
||||
|
||||
/**
|
||||
* This is the receiver that is triggered by the [PackageInstaller] to notify of various events. Package installation is initiated
|
||||
* in [ApkUpdateInstaller].
|
||||
*/
|
||||
class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ApkUpdatePackageInstallerReceiver::class.java)
|
||||
|
||||
const val EXTRA_USER_INITIATED = "signal.user_initiated"
|
||||
const val EXTRA_DOWNLOAD_ID = "signal.download_id"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent?) {
|
||||
val statusCode: Int = intent?.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) ?: -1
|
||||
val statusMessage: String? = intent?.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
val userInitiated = intent?.getBooleanExtra(EXTRA_USER_INITIATED, false) ?: false
|
||||
|
||||
Log.w(TAG, "[onReceive] Status: $statusCode, Message: $statusMessage")
|
||||
|
||||
when (statusCode) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
Log.i(TAG, "Update installed successfully!")
|
||||
ApkUpdateNotifications.showAutoUpdateSuccess(context)
|
||||
}
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!)
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.ABORTED)
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.BLOCKED)
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INCOMPATIBLE)
|
||||
PackageInstaller.STATUS_FAILURE_INVALID -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INVALID)
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.CONFLICT)
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.STORAGE)
|
||||
PackageInstaller.STATUS_FAILURE_TIMEOUT -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.TIMEOUT)
|
||||
PackageInstaller.STATUS_FAILURE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.UNKNOWN)
|
||||
else -> Log.w(TAG, "Unknown status! $statusCode")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePendingUserAction(context: Context, userInitiated: Boolean, intent: Intent) {
|
||||
val downloadId = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -2)
|
||||
|
||||
if (!userInitiated) {
|
||||
Log.w(TAG, "Not user-initiated, but needs user action! Showing prompt notification.")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
val promptIntent: Intent? = intent.getParcelableExtraCompat(Intent.EXTRA_INTENT, Intent::class.java)
|
||||
if (promptIntent == null) {
|
||||
Log.w(TAG, "Missing prompt intent! Showing prompt notification instead.")
|
||||
ApkUpdateNotifications.showInstallPrompt(context, downloadId)
|
||||
return
|
||||
}
|
||||
|
||||
promptIntent.apply {
|
||||
putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true)
|
||||
putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, "com.android.vending")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
context.startActivity(promptIntent)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
package org.thoughtcrime.securesms.service;
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.apkupdate;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
@@ -6,16 +11,18 @@ import android.content.Context;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.UpdateApkJob;
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob;
|
||||
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
|
||||
public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener {
|
||||
|
||||
private static final String TAG = Log.tag(UpdateApkRefreshListener.class);
|
||||
private static final String TAG = Log.tag(ApkUpdateRefreshListener.class);
|
||||
|
||||
private static final long INTERVAL = TimeUnit.HOURS.toMillis(6);
|
||||
private static final long INTERVAL = Environment.IS_NIGHTLY ? TimeUnit.HOURS.toMillis(2) : TimeUnit.HOURS.toMillis(6);
|
||||
|
||||
@Override
|
||||
protected long getNextScheduledExecutionTime(Context context) {
|
||||
@@ -26,9 +33,9 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
|
||||
protected long onAlarm(Context context, long scheduledTime) {
|
||||
Log.i(TAG, "onAlarm...");
|
||||
|
||||
if (scheduledTime != 0 && BuildConfig.PLAY_STORE_DISABLED) {
|
||||
if (scheduledTime != 0 && BuildConfig.MANAGES_APP_UPDATES) {
|
||||
Log.i(TAG, "Queueing APK update job...");
|
||||
ApplicationDependencies.getJobManager().add(new UpdateApkJob());
|
||||
ApplicationDependencies.getJobManager().add(new ApkUpdateJob());
|
||||
}
|
||||
|
||||
long newTime = System.currentTimeMillis() + INTERVAL;
|
||||
@@ -38,7 +45,7 @@ public class UpdateApkRefreshListener extends PersistentAlarmManagerListener {
|
||||
}
|
||||
|
||||
public static void schedule(Context context) {
|
||||
new UpdateApkRefreshListener().onReceive(context, getScheduleIntent());
|
||||
new ApkUpdateRefreshListener().onReceive(context, getScheduleIntent());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import androidx.annotation.Nullable;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.signal.core.util.Base64;
|
||||
import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
@@ -108,7 +108,7 @@ public class PointerAttachment extends Attachment {
|
||||
String encodedKey = null;
|
||||
|
||||
if (pointer.get().asPointer().getKey() != null) {
|
||||
encodedKey = Base64.encodeBytes(pointer.get().asPointer().getKey());
|
||||
encodedKey = Base64.encodeWithPadding(pointer.get().asPointer().getKey());
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
||||
@@ -144,7 +144,7 @@ public class PointerAttachment extends Attachment {
|
||||
pointer.getFileName(),
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
@@ -175,7 +175,7 @@ public class PointerAttachment extends Attachment {
|
||||
quotedAttachment.fileName,
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeWithPadding(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.whispersystems.util.Base64;
|
||||
import org.signal.core.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
@@ -27,7 +27,7 @@ public final class AudioHash implements Parcelable {
|
||||
}
|
||||
|
||||
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
this(Base64.encodeBytes(audioWaveForm.encode()), audioWaveForm);
|
||||
this(Base64.encodeWithPadding(audioWaveForm.encode()), audioWaveForm);
|
||||
}
|
||||
|
||||
protected AudioHash(Parcel in) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSize
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.glide.GiftBadgeModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
@@ -31,6 +32,10 @@ class BadgeImageView @JvmOverloads constructor(
|
||||
isClickable = false
|
||||
}
|
||||
|
||||
constructor(context: Context, badgeImageSize: BadgeImageSize) : this(context) {
|
||||
badgeSize = badgeImageSize.sizeCode
|
||||
}
|
||||
|
||||
override fun setOnClickListener(l: OnClickListener?) {
|
||||
val wasClickable = isClickable
|
||||
super.setOnClickListener(l)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import android.content.Context
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import java.lang.Integer.min
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -32,7 +32,7 @@ object Gifts {
|
||||
): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = recipient,
|
||||
body = Base64.encodeBytes(giftBadge.encode()),
|
||||
body = Base64.encodeWithPadding(giftBadge.encode()),
|
||||
isSecure = true,
|
||||
sentTimeMillis = sentTimestamp,
|
||||
expiresIn = expiresIn,
|
||||
|
||||
@@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
@@ -263,8 +264,12 @@ class GiftFlowConfirmationFragment :
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
|
||||
error("Unsupported operation")
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
|
||||
error("Unsupported operation")
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
@@ -280,7 +285,10 @@ class GiftFlowConfirmationFragment :
|
||||
}
|
||||
|
||||
override fun onProcessorActionProcessed() = Unit
|
||||
|
||||
override fun onUserCancelledPaymentFlow() {
|
||||
findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false)
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Parcelable
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.bumptech.glide.load.Key
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
@@ -25,6 +26,7 @@ typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
|
||||
/**
|
||||
* A Badge that can be collected and displayed by a user.
|
||||
*/
|
||||
@Stable
|
||||
@Parcelize
|
||||
data class Badge(
|
||||
val id: String,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
@@ -38,6 +39,7 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
val badge: Badge = args.badge
|
||||
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
|
||||
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
|
||||
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
|
||||
|
||||
@@ -69,6 +71,12 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
getString(declineCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (failureCode != null) {
|
||||
getString(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
|
||||
getString(failureCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (inactive) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
|
||||
} else {
|
||||
|
||||
@@ -58,7 +58,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
space(DimensionUnit.DP.toPixels(32f).toInt())
|
||||
|
||||
tonalButton(
|
||||
tonalWrappedButton(
|
||||
text = DSLSettingsText.from(
|
||||
R.string.BecomeASustainerMegaphone__become_a_sustainer
|
||||
),
|
||||
|
||||
@@ -23,7 +23,7 @@ import java.net.URLDecoder
|
||||
object CallLinks {
|
||||
private const val ROOT_KEY = "key"
|
||||
private const val HTTPS_LINK_PREFIX = "https://signal.link/call/#key="
|
||||
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/#key="
|
||||
private const val SNGL_LINK_PREFIX = "sgnl://signal.link/call/#key="
|
||||
|
||||
private val TAG = Log.tag(CallLinks::class.java)
|
||||
|
||||
|
||||
@@ -88,11 +88,11 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
disposables += viewModel.submitLogs().subscribe({ result ->
|
||||
submitLogs(result, purpose)
|
||||
progressDialog.dismiss()
|
||||
dismiss()
|
||||
dismissAllowingStateLoss()
|
||||
}, { _ ->
|
||||
Toast.makeText(requireContext(), getString(R.string.HelpFragment__could_not_upload_logs), Toast.LENGTH_LONG).show()
|
||||
progressDialog.dismiss()
|
||||
dismiss()
|
||||
dismissAllowingStateLoss()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
SignalStore.uiHints().markDeclinedShareNotificationLogs()
|
||||
}
|
||||
|
||||
dismiss()
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ public class RatingManager {
|
||||
private static final String TAG = Log.tag(RatingManager.class);
|
||||
|
||||
public static void showRatingDialogIfNecessary(Context context) {
|
||||
if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.PLAY_STORE_DISABLED) return;
|
||||
if (!TextSecurePreferences.isRatingEnabled(context) || BuildConfig.MANAGES_APP_UPDATES) return;
|
||||
|
||||
long daysSinceInstall = VersionTracker.getDaysSinceFirstInstalled(context);
|
||||
long laterTimestamp = TextSecurePreferences.getRatingLaterTimestamp(context);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
|
||||
/**
|
||||
* Applies temporary screenshot security for the given component lifecycle.
|
||||
*/
|
||||
object TemporaryScreenshotSecurity {
|
||||
|
||||
@JvmStatic
|
||||
fun bindToViewLifecycleOwner(fragment: Fragment) {
|
||||
val observer = LifecycleObserver { fragment.requireActivity() }
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(observer)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun bind(activity: ComponentActivity) {
|
||||
val observer = LifecycleObserver { activity }
|
||||
|
||||
activity.lifecycle.addObserver(observer)
|
||||
}
|
||||
|
||||
private class LifecycleObserver(
|
||||
private val activityProvider: () -> ComponentActivity
|
||||
) : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
val activity = activityProvider()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
val activity = activityProvider()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(activity)) {
|
||||
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -50,6 +51,8 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
private lateinit var reminderView: Stub<ReminderView>
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewLifecycleOwner.lifecycle.addObserver(TerminalDonationDelegate(childFragmentManager, viewLifecycleOwner))
|
||||
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
reminderView = ViewUtil.findStubById(view, R.id.reminder_stub)
|
||||
|
||||
|
||||
@@ -307,8 +307,8 @@ class ChangeNumberRepository(
|
||||
return Single.fromCallable {
|
||||
for (certificateType in certificateTypes) {
|
||||
val certificate: ByteArray? = when (certificateType) {
|
||||
CertificateType.UUID_AND_E164 -> accountManager.getSenderCertificate()
|
||||
CertificateType.UUID_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
|
||||
CertificateType.ACI_AND_E164 -> accountManager.getSenderCertificate()
|
||||
CertificateType.ACI_ONLY -> accountManager.getSenderCertificateForPhoneNumberPrivacy()
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueCodeSelector
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Allows configuration of a PendingOneTimeDonation object to display different
|
||||
* states in the donation settings screen.
|
||||
*/
|
||||
class InternalPendingOneTimeDonationConfigurationFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: InternalPendingOneTimeDonationConfigurationViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
Content(
|
||||
state,
|
||||
onNavigationClick = {
|
||||
findNavController().popBackStack()
|
||||
},
|
||||
onAddError = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = DonationErrorValue())
|
||||
},
|
||||
onClearError = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = null)
|
||||
},
|
||||
onPaymentMethodTypeSelected = {
|
||||
viewModel.state.value = viewModel.state.value.copy(paymentMethodType = it, error = null)
|
||||
},
|
||||
onErrorTypeSelected = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(type = it))
|
||||
},
|
||||
onErrorCodeChanged = {
|
||||
viewModel.state.value = viewModel.state.value.copy(error = viewModel.state.value.error!!.copy(code = it))
|
||||
},
|
||||
onSave = {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(viewModel.state.value)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Content(
|
||||
state = PendingOneTimeDonation.Builder().error(DonationErrorValue()).build(),
|
||||
onNavigationClick = {},
|
||||
onClearError = {},
|
||||
onAddError = {},
|
||||
onPaymentMethodTypeSelected = {},
|
||||
onErrorTypeSelected = {},
|
||||
onErrorCodeChanged = {},
|
||||
onSave = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Content(
|
||||
state: PendingOneTimeDonation,
|
||||
onNavigationClick: () -> Unit,
|
||||
onAddError: () -> Unit,
|
||||
onClearError: () -> Unit,
|
||||
onPaymentMethodTypeSelected: (PendingOneTimeDonation.PaymentMethodType) -> Unit,
|
||||
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit,
|
||||
onErrorCodeChanged: (String) -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "One-time donation state",
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24),
|
||||
navigationContentDescription = null,
|
||||
onNavigationClick = onNavigationClick
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier.padding(it)
|
||||
) {
|
||||
item {
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = state.paymentMethodType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
PendingOneTimeDonation.PaymentMethodType.values().forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item.name) },
|
||||
onClick = {
|
||||
onPaymentMethodTypeSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = state.error != null,
|
||||
text = "Enable error",
|
||||
onCheckChanged = {
|
||||
if (it) {
|
||||
onAddError()
|
||||
} else {
|
||||
onClearError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
item {
|
||||
DonationErrorValueTypeSelector(
|
||||
selectedPaymentMethodType = state.paymentMethodType,
|
||||
selectedErrorType = state.error.type,
|
||||
onErrorTypeSelected = onErrorTypeSelected
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
DonationErrorValueCodeSelector(
|
||||
selectedPaymentMethodType = state.paymentMethodType,
|
||||
selectedErrorType = state.error.type,
|
||||
selectedErrorCode = state.error.code,
|
||||
onErrorCodeSelected = onErrorCodeChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.badge != null,
|
||||
onClick = onSave
|
||||
) {
|
||||
Text(text = "Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Fetches a badge for our pending donation, which requires downloading the donation config.
|
||||
*/
|
||||
class InternalPendingOneTimeDonationConfigurationViewModel : ViewModel() {
|
||||
|
||||
val state: MutableState<PendingOneTimeDonation> = mutableStateOf(
|
||||
PendingOneTimeDonation(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
amount = FiatMoney(BigDecimal.valueOf(20), Currency.getInstance("EUR")).toFiatValue()
|
||||
)
|
||||
)
|
||||
|
||||
val disposable: Disposable = Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies.getDonationsService()
|
||||
.getDonationsConfiguration(Locale.getDefault())
|
||||
}
|
||||
.flatMap { it.flattenResult() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { config ->
|
||||
val badge = Badges.fromServiceBadge(config.levels.values.first().badge)
|
||||
state.value = state.value.copy(badge = Badges.toDatabaseBadge(badge))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.AppUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -21,10 +25,13 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase
|
||||
import org.thoughtcrime.securesms.database.OneTimePreKeyTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob
|
||||
@@ -53,6 +60,10 @@ import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(InternalSettingsFragment::class.java)
|
||||
}
|
||||
|
||||
private lateinit var viewModel: InternalSettingsViewModel
|
||||
|
||||
private var scrollToPosition: Int = 0
|
||||
@@ -161,6 +172,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all logs"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).logs.clearAll()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared all logs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear keep longer logs"),
|
||||
onClick = {
|
||||
@@ -168,6 +190,47 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all crashes"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).crashes.clear()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared crashes", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all ANRs"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).anrs.clear()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared ANRs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Log dump PreKey ServiceId-KeyIds"),
|
||||
onClick = {
|
||||
logPreKeyIds()
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Retry all jobs now"),
|
||||
summary = DSLSettingsText.from("Clear backoff intervals, app will restart"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
JobDatabase.getInstance(ApplicationDependencies.getApplication()).debugResetBackoffInterval()
|
||||
}) {
|
||||
AppUtil.restart(requireContext())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Payments"))
|
||||
@@ -459,6 +522,29 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
)
|
||||
}
|
||||
|
||||
if (state.hasPendingOneTimeDonation) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear pending one-time donation."),
|
||||
onClick = {
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(null)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Set pending one-time donation."),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToOneTimeDonationConfigurationFragment())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Enqueue terminal donation"),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(InternalSettingsFragmentDirections.actionInternalSettingsFragmentToTerminalDonationConfigurationFragment())
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Release channel"))
|
||||
@@ -725,7 +811,12 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionRedemption() {
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(
|
||||
-1L,
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = 1000
|
||||
)
|
||||
).enqueue()
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionKeepAlive() {
|
||||
@@ -773,4 +864,19 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
Toast.makeText(requireContext(), "Cleared keep longer logs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun logPreKeyIds() {
|
||||
SimpleTask.run({
|
||||
val oneTimePreKeys = SignalDatabase.rawDatabase
|
||||
.query("SELECT * FROM ${OneTimePreKeyTable.TABLE_NAME}")
|
||||
.readToList { c ->
|
||||
c.requireString(OneTimePreKeyTable.ACCOUNT_ID) to c.requireLong(OneTimePreKeyTable.KEY_ID)
|
||||
}
|
||||
.joinToString()
|
||||
|
||||
Log.i(TAG, "One-Time Prekeys\n$oneTimePreKeys")
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Dumped to logs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ data class InternalSettingsState(
|
||||
val disableStorageService: Boolean,
|
||||
val canClearOnboardingState: Boolean,
|
||||
val pnpInitialized: Boolean,
|
||||
val useConversationItemV2ForMedia: Boolean
|
||||
val useConversationItemV2ForMedia: Boolean,
|
||||
val hasPendingOneTimeDonation: Boolean
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.ringrtc.CallManager
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob
|
||||
import org.thoughtcrime.securesms.keyvalue.InternalValues
|
||||
@@ -20,6 +21,14 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
repository.getEmojiVersionInfo { version ->
|
||||
store.update { it.copy(emojiVersion = version) }
|
||||
}
|
||||
|
||||
val pendingOneTimeDonation: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.distinctUntilChanged()
|
||||
.map { it.isPresent }
|
||||
|
||||
store.update(pendingOneTimeDonation) { pending, state ->
|
||||
state.copy(hasPendingOneTimeDonation = pending)
|
||||
}
|
||||
}
|
||||
|
||||
val state: LiveData<InternalSettingsState> = store.stateLiveData
|
||||
@@ -136,7 +145,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
disableStorageService = SignalStore.internalValues().storageServiceDisabled(),
|
||||
canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(),
|
||||
pnpInitialized = SignalStore.misc().hasPniInitializedDevices(),
|
||||
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media()
|
||||
useConversationItemV2ForMedia = SignalStore.internalValues().useConversationItemV2Media(),
|
||||
hasPendingOneTimeDonation = SignalStore.donationsValues().getPendingOneTimeDonation() != null
|
||||
)
|
||||
|
||||
fun onClearOnboardingState() {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Rows
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueCodeSelector
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.donor.DonationErrorValueTypeSelector
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Configuration fragment for [TerminalDonationQueue.TerminalDonation]
|
||||
*/
|
||||
class InternalTerminalDonationConfigurationFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
InternalTerminalDonationConfigurationContent(
|
||||
onAddClick = {
|
||||
SignalStore.donationsValues().appendToTerminalDonationQueue(it)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun InternalTerminalDonationConfigurationContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
InternalTerminalDonationConfigurationContent(
|
||||
onAddClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InternalTerminalDonationConfigurationContent(
|
||||
onAddClick: (TerminalDonationQueue.TerminalDonation) -> Unit
|
||||
) {
|
||||
val terminalDonationState: MutableState<TerminalDonationQueue.TerminalDonation> = remember {
|
||||
mutableStateOf(
|
||||
TerminalDonationQueue.TerminalDonation(
|
||||
level = 1000L,
|
||||
isLongRunningPaymentMethod = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val paymentMethodType = remember(terminalDonationState.value.isLongRunningPaymentMethod) {
|
||||
if (terminalDonationState.value.isLongRunningPaymentMethod) PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT else PendingOneTimeDonation.PaymentMethodType.CARD
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = terminalDonationState.value.isLongRunningPaymentMethod,
|
||||
text = "Long-running payment method",
|
||||
onCheckChanged = {
|
||||
terminalDonationState.value = terminalDonationState.value.copy(isLongRunningPaymentMethod = it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Rows.ToggleRow(
|
||||
checked = terminalDonationState.value.error != null,
|
||||
text = "Enable error",
|
||||
onCheckChanged = {
|
||||
val error = if (it) {
|
||||
DonationErrorValue()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
terminalDonationState.value = terminalDonationState.value.copy(error = error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val error = terminalDonationState.value.error
|
||||
if (error != null) {
|
||||
item {
|
||||
DonationErrorValueTypeSelector(
|
||||
selectedPaymentMethodType = paymentMethodType,
|
||||
selectedErrorType = error.type,
|
||||
onErrorTypeSelected = {
|
||||
terminalDonationState.value = terminalDonationState.value.copy(
|
||||
error = error.copy(
|
||||
type = it,
|
||||
code = ""
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
DonationErrorValueCodeSelector(
|
||||
selectedPaymentMethodType = paymentMethodType,
|
||||
selectedErrorType = error.type,
|
||||
selectedErrorCode = error.code,
|
||||
onErrorCodeSelected = {
|
||||
terminalDonationState.value = terminalDonationState.value.copy(
|
||||
error = error.copy(
|
||||
code = it
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Buttons.LargeTonal(
|
||||
onClick = { onAddClick(terminalDonationState.value) },
|
||||
modifier = Modifier.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = "Confirm")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.donor
|
||||
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.PayPalDeclineCode
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
|
||||
/**
|
||||
* Displays a dropdown widget for selecting an error type.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DonationErrorValueTypeSelector(
|
||||
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
selectedErrorType: DonationErrorValue.Type,
|
||||
onErrorTypeSelected: (DonationErrorValue.Type) -> Unit
|
||||
) {
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = selectedErrorType.name,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
DonationErrorValue.Type.values().filterNot {
|
||||
selectedPaymentMethodType == PendingOneTimeDonation.PaymentMethodType.PAYPAL && it == DonationErrorValue.Type.FAILURE_CODE
|
||||
}.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item.name) },
|
||||
onClick = {
|
||||
onErrorTypeSelected(item)
|
||||
expanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a dropdown widget for selecting an error code, if the corresponding type
|
||||
* allows for such things.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DonationErrorValueCodeSelector(
|
||||
selectedPaymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
selectedErrorType: DonationErrorValue.Type,
|
||||
selectedErrorCode: String,
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val isCodedError = remember(selectedErrorType) {
|
||||
selectedErrorType in setOf(DonationErrorValue.Type.PROCESSOR_CODE, DonationErrorValue.Type.DECLINE_CODE, DonationErrorValue.Type.FAILURE_CODE)
|
||||
}
|
||||
|
||||
var expanded by remember {
|
||||
mutableStateOf(false)
|
||||
}
|
||||
|
||||
if (isCodedError) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = {
|
||||
expanded = !expanded
|
||||
}
|
||||
) {
|
||||
TextField(
|
||||
value = selectedErrorCode,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
modifier = Modifier.menuAnchor()
|
||||
)
|
||||
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
when (selectedErrorType) {
|
||||
DonationErrorValue.Type.PROCESSOR_CODE -> {
|
||||
ProcessorErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
|
||||
}
|
||||
|
||||
DonationErrorValue.Type.DECLINE_CODE -> {
|
||||
DeclineCodeErrorsDropdown(selectedPaymentMethodType, onErrorCodeSelected)
|
||||
}
|
||||
|
||||
DonationErrorValue.Type.FAILURE_CODE -> {
|
||||
FailureCodeErrorsDropdown(onErrorCodeSelected)
|
||||
}
|
||||
|
||||
else -> error("This should never happen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProcessorErrorsDropdown(
|
||||
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = when (paymentMethodType) {
|
||||
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> arrayOf("2046", "2074")
|
||||
else -> arrayOf("currency_not_supported", "call_issuer")
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeclineCodeErrorsDropdown(
|
||||
paymentMethodType: PendingOneTimeDonation.PaymentMethodType,
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = remember(paymentMethodType) {
|
||||
when (paymentMethodType) {
|
||||
PendingOneTimeDonation.PaymentMethodType.PAYPAL -> PayPalDeclineCode.KnownCode.values()
|
||||
else -> StripeDeclineCode.Code.values()
|
||||
}.map { it.name }.toTypedArray()
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FailureCodeErrorsDropdown(
|
||||
onErrorCodeSelected: (String) -> Unit
|
||||
) {
|
||||
val values = remember {
|
||||
StripeFailureCode.Code.values().map { it.name }.toTypedArray()
|
||||
}
|
||||
|
||||
ValuesDropdown(values = values, onErrorCodeSelected = onErrorCodeSelected)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValuesDropdown(values: Array<String>, onErrorCodeSelected: (String) -> Unit) {
|
||||
values.forEach { item ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(text = item) },
|
||||
onClick = {
|
||||
onErrorCodeSelected(item)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ fun SvrPlaygroundScreenLightTheme() {
|
||||
Surface {
|
||||
SvrPlaygroundScreen(
|
||||
state = InternalSvrPlaygroundState(
|
||||
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
|
||||
options = persistentListOf(SvrImplementation.SVR2)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -138,7 +138,7 @@ fun SvrPlaygroundScreenDarkTheme() {
|
||||
Surface {
|
||||
SvrPlaygroundScreen(
|
||||
state = InternalSvrPlaygroundState(
|
||||
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
|
||||
options = persistentListOf(SvrImplementation.SVR2)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,5 +13,5 @@ data class InternalSvrPlaygroundState(
|
||||
enum class SvrImplementation(
|
||||
val title: String
|
||||
) {
|
||||
SVR1("KBS"), SVR2("SVR2")
|
||||
SVR2("SVR2")
|
||||
}
|
||||
|
||||
@@ -19,13 +19,12 @@ import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecovery
|
||||
import org.whispersystems.signalservice.api.svr.SecureValueRecoveryV1
|
||||
|
||||
class InternalSvrPlaygroundViewModel : ViewModel() {
|
||||
|
||||
private val _state: MutableState<InternalSvrPlaygroundState> = mutableStateOf(
|
||||
InternalSvrPlaygroundState(
|
||||
options = persistentListOf(SvrImplementation.SVR1, SvrImplementation.SVR2)
|
||||
options = persistentListOf(SvrImplementation.SVR2)
|
||||
)
|
||||
)
|
||||
val state: State<InternalSvrPlaygroundState> = _state
|
||||
@@ -104,7 +103,6 @@ class InternalSvrPlaygroundViewModel : ViewModel() {
|
||||
|
||||
private fun SvrImplementation.toImplementation(): SecureValueRecovery {
|
||||
return when (this) {
|
||||
SvrImplementation.SVR1 -> SecureValueRecoveryV1(ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE))
|
||||
SvrImplementation.SVR2 -> ApplicationDependencies.getSignalServiceAccountManager().getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
textId = R.string.NotificationSettingsFragment__to_enable_notifications,
|
||||
actionId = R.string.NotificationSettingsFragment__turn_on,
|
||||
onClick = {
|
||||
TurnOnNotificationsBottomSheet().show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
TurnOnNotificationsBottomSheet.turnOnSystemNotificationsFragment(requireContext()).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -25,8 +25,6 @@ import org.signal.core.ui.Texts
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberListingMode
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberSharingMode
|
||||
|
||||
class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
|
||||
@@ -67,7 +65,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
|
||||
item {
|
||||
Rows.RadioRow(
|
||||
selected = state.seeMyPhoneNumber == PhoneNumberSharingMode.EVERYONE,
|
||||
selected = state.phoneNumberSharing,
|
||||
text = stringResource(id = R.string.PhoneNumberPrivacy_everyone),
|
||||
modifier = Modifier.clickable(onClick = viewModel::setEveryoneCanSeeMyNumber)
|
||||
)
|
||||
@@ -75,7 +73,7 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
|
||||
item {
|
||||
Rows.RadioRow(
|
||||
selected = state.seeMyPhoneNumber == PhoneNumberSharingMode.NOBODY,
|
||||
selected = !state.phoneNumberSharing,
|
||||
text = stringResource(id = R.string.PhoneNumberPrivacy_nobody),
|
||||
modifier = Modifier.clickable(onClick = viewModel::setNobodyCanSeeMyNumber)
|
||||
)
|
||||
@@ -84,10 +82,10 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = when (state.seeMyPhoneNumber) {
|
||||
PhoneNumberSharingMode.EVERYONE -> R.string.PhoneNumberPrivacySettingsFragment__your_phone_number
|
||||
PhoneNumberSharingMode.NOBODY -> R.string.PhoneNumberPrivacySettingsFragment__nobody_will_see
|
||||
else -> error("Unexpected state $state")
|
||||
id = if (state.phoneNumberSharing) {
|
||||
R.string.PhoneNumberPrivacySettingsFragment__your_phone_number
|
||||
} else {
|
||||
R.string.PhoneNumberPrivacySettingsFragment__nobody_will_see
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -106,16 +104,16 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
|
||||
item {
|
||||
Rows.RadioRow(
|
||||
selected = state.findMeByPhoneNumber == PhoneNumberListingMode.LISTED,
|
||||
selected = state.discoverableByPhoneNumber,
|
||||
text = stringResource(id = R.string.PhoneNumberPrivacy_everyone),
|
||||
modifier = Modifier.clickable(onClick = viewModel::setEveryoneCanFindMeByMyNumber)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.seeMyPhoneNumber == PhoneNumberSharingMode.NOBODY) {
|
||||
if (!state.phoneNumberSharing) {
|
||||
item {
|
||||
Rows.RadioRow(
|
||||
selected = state.findMeByPhoneNumber == PhoneNumberListingMode.UNLISTED,
|
||||
selected = !state.discoverableByPhoneNumber,
|
||||
text = stringResource(id = R.string.PhoneNumberPrivacy_nobody),
|
||||
modifier = Modifier.clickable(onClick = viewModel::setNobodyCanFindMeByMyNumber)
|
||||
)
|
||||
@@ -125,9 +123,10 @@ class PhoneNumberPrivacySettingsFragment : ComposeFragment() {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = when (state.findMeByPhoneNumber) {
|
||||
PhoneNumberListingMode.UNLISTED -> R.string.WhoCanSeeMyPhoneNumberFragment__nobody_on_signal
|
||||
PhoneNumberListingMode.LISTED -> R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has
|
||||
id = if (state.discoverableByPhoneNumber) {
|
||||
R.string.WhoCanSeeMyPhoneNumberFragment__anyone_who_has
|
||||
} else {
|
||||
R.string.WhoCanSeeMyPhoneNumberFragment__nobody_on_signal
|
||||
}
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.privacy.pnp
|
||||
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
|
||||
|
||||
data class PhoneNumberPrivacySettingsState(
|
||||
val seeMyPhoneNumber: PhoneNumberPrivacyValues.PhoneNumberSharingMode,
|
||||
val findMeByPhoneNumber: PhoneNumberPrivacyValues.PhoneNumberListingMode
|
||||
val phoneNumberSharing: Boolean,
|
||||
val discoverableByPhoneNumber: Boolean
|
||||
)
|
||||
|
||||
@@ -17,39 +17,39 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
|
||||
private val _state = mutableStateOf(
|
||||
PhoneNumberPrivacySettingsState(
|
||||
seeMyPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode,
|
||||
findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode
|
||||
phoneNumberSharing = SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled,
|
||||
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().isDiscoverableByPhoneNumber
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<PhoneNumberPrivacySettingsState> = _state
|
||||
|
||||
fun setNobodyCanSeeMyNumber() {
|
||||
setPhoneNumberSharingMode(PhoneNumberSharingMode.NOBODY)
|
||||
setPhoneNumberSharingEnabled(false)
|
||||
}
|
||||
|
||||
fun setEveryoneCanSeeMyNumber() {
|
||||
setPhoneNumberSharingMode(PhoneNumberSharingMode.EVERYONE)
|
||||
setPhoneNumberListingMode(PhoneNumberListingMode.LISTED)
|
||||
setPhoneNumberSharingEnabled(true)
|
||||
setDiscoverableByPhoneNumber(true)
|
||||
}
|
||||
|
||||
fun setNobodyCanFindMeByMyNumber() {
|
||||
setPhoneNumberListingMode(PhoneNumberListingMode.UNLISTED)
|
||||
setDiscoverableByPhoneNumber(false)
|
||||
}
|
||||
|
||||
fun setEveryoneCanFindMeByMyNumber() {
|
||||
setPhoneNumberListingMode(PhoneNumberListingMode.LISTED)
|
||||
setDiscoverableByPhoneNumber(true)
|
||||
}
|
||||
|
||||
private fun setPhoneNumberSharingMode(phoneNumberSharingMode: PhoneNumberSharingMode) {
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = phoneNumberSharingMode
|
||||
private fun setPhoneNumberSharingEnabled(phoneNumberSharingEnabled: Boolean) {
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberSharingMode = if (phoneNumberSharingEnabled) PhoneNumberSharingMode.EVERYBODY else PhoneNumberSharingMode.NOBODY
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun setPhoneNumberListingMode(phoneNumberListingMode: PhoneNumberListingMode) {
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = phoneNumberListingMode
|
||||
private fun setDiscoverableByPhoneNumber(discoverable: Boolean) {
|
||||
SignalStore.phoneNumberPrivacy().phoneNumberListingMode = if (discoverable) PhoneNumberListingMode.LISTED else PhoneNumberListingMode.UNLISTED
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
ApplicationDependencies.getJobManager().startChain(RefreshAttributesJob()).then(RefreshOwnProfileJob()).enqueue()
|
||||
refresh()
|
||||
@@ -57,8 +57,8 @@ class PhoneNumberPrivacySettingsViewModel : ViewModel() {
|
||||
|
||||
fun refresh() {
|
||||
_state.value = PhoneNumberPrivacySettingsState(
|
||||
seeMyPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberSharingMode,
|
||||
findMeByPhoneNumber = SignalStore.phoneNumberPrivacy().phoneNumberListingMode
|
||||
phoneNumberSharing = SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled,
|
||||
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy().isDiscoverableByPhoneNumber
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
enum class BadgeImageSize(val sizeCode: Int) {
|
||||
SMALL(0),
|
||||
MEDIUM(1),
|
||||
LARGE(2),
|
||||
X_LARGE(3),
|
||||
BADGE_64(4),
|
||||
BADGE_112(5)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImage112(
|
||||
badge: Badge?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = {
|
||||
BadgeImageView(it, BadgeImageSize.BADGE_112)
|
||||
},
|
||||
update = {
|
||||
it.setBadge(badge)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
|
||||
/**
|
||||
* Displayed after the user completes the donation flow for a bank transfer.
|
||||
*/
|
||||
class DonationPendingBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
private val args: DonationPendingBottomSheetArgs by navArgs()
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
DonationPendingBottomSheetContent(
|
||||
badge = args.request.badge,
|
||||
onDoneClick = this::onDoneClick
|
||||
)
|
||||
}
|
||||
|
||||
private fun onDoneClick() {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
|
||||
if (args.request.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
findNavController().popBackStack()
|
||||
} else {
|
||||
requireActivity().finish()
|
||||
requireActivity().startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun DonationPendingBottomSheetContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
DonationPendingBottomSheetContent(
|
||||
badge = Badge(
|
||||
id = "",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Star",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "",
|
||||
expirationTimestamp = 0L,
|
||||
visible = true,
|
||||
duration = 0L
|
||||
),
|
||||
onDoneClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DonationPendingBottomSheetContent(
|
||||
badge: Badge,
|
||||
onDoneClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 44.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
BadgeImage112(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.padding(top = 21.dp, bottom = 16.dp)
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationPendingBottomSheet__donation_pending),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
val textResource = if (badge.isSubscription()) {
|
||||
R.string.DonationPendingBottomSheet__your_monthly_donation_is_pending
|
||||
} else {
|
||||
R.string.DonationPendingBottomSheet__your_one_time_donation_is_pending
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = textResource, badge.name),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val learnMore = stringResource(id = R.string.DonationPendingBottomSheet__learn_more)
|
||||
val fullString = stringResource(id = R.string.DonationPendingBottomSheet__bank_transfers_usually_take, learnMore)
|
||||
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.pending_transfer_url))
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = spanned,
|
||||
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
|
||||
style = LocalTextStyle.current.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
modifier = Modifier.padding(bottom = 48.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onDoneClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.DonationPendingBottomSheet__done))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
|
||||
import okio.ByteString
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecimalValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.FiatValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.MathContext
|
||||
import java.util.Currency
|
||||
|
||||
object DonationSerializationHelper {
|
||||
fun createPendingOneTimeDonationProto(
|
||||
badge: Badge,
|
||||
paymentSourceType: PaymentSourceType,
|
||||
amount: FiatMoney
|
||||
): PendingOneTimeDonation {
|
||||
return PendingOneTimeDonation(
|
||||
badge = Badges.toDatabaseBadge(badge),
|
||||
paymentMethodType = when (paymentSourceType) {
|
||||
PaymentSourceType.PayPal -> PendingOneTimeDonation.PaymentMethodType.PAYPAL
|
||||
PaymentSourceType.Stripe.CreditCard, PaymentSourceType.Stripe.GooglePay, PaymentSourceType.Unknown -> PendingOneTimeDonation.PaymentMethodType.CARD
|
||||
PaymentSourceType.Stripe.SEPADebit -> PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT
|
||||
PaymentSourceType.Stripe.IDEAL -> PendingOneTimeDonation.PaymentMethodType.IDEAL
|
||||
},
|
||||
amount = amount.toFiatValue(),
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
fun FiatValue.toFiatMoney(): FiatMoney {
|
||||
return FiatMoney(
|
||||
amount!!.toBigDecimal(),
|
||||
Currency.getInstance(currencyCode)
|
||||
)
|
||||
}
|
||||
|
||||
fun DecimalValue.toBigDecimal(): BigDecimal {
|
||||
return BigDecimal(
|
||||
BigInteger(value_.toByteArray()),
|
||||
scale,
|
||||
MathContext(precision)
|
||||
)
|
||||
}
|
||||
|
||||
fun FiatMoney.toFiatValue(): FiatValue {
|
||||
return FiatValue(
|
||||
currencyCode = currency.currencyCode,
|
||||
amount = amount.toDecimalValue()
|
||||
)
|
||||
}
|
||||
|
||||
fun BigDecimal.toDecimalValue(): DecimalValue {
|
||||
return DecimalValue(
|
||||
scale = scale(),
|
||||
precision = precision(),
|
||||
value_ = ByteString.of(*this.unscaledValue().toByteArray())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.LocaleFeatureFlags
|
||||
|
||||
@@ -20,7 +21,7 @@ object InAppDonations {
|
||||
* - Able to use PayPal and is in a region where it is able to be accepted.
|
||||
*/
|
||||
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
|
||||
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable()
|
||||
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable() || isIDEALAvailable()
|
||||
}
|
||||
|
||||
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
|
||||
@@ -28,7 +29,8 @@ object InAppDonations {
|
||||
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
|
||||
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
|
||||
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailable()
|
||||
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailableForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Stripe.IDEAL -> isIDEALAvailbleForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Unknown -> false
|
||||
}
|
||||
}
|
||||
@@ -65,6 +67,29 @@ object InAppDonations {
|
||||
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number.
|
||||
*/
|
||||
fun isSEPADebitAvailable(): Boolean {
|
||||
return FeatureFlags.sepaDebitDonations()
|
||||
return Environment.IS_STAGING || (FeatureFlags.sepaDebitDonations() && LocaleFeatureFlags.isSepaEnabled())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user is in a region which supports IDEAL transfers, based off local phone number.
|
||||
*/
|
||||
fun isIDEALAvailable(): Boolean {
|
||||
return Environment.IS_STAGING || (FeatureFlags.idealDonations() && LocaleFeatureFlags.isIdealEnabled())
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number
|
||||
* and donation type.
|
||||
*/
|
||||
fun isSEPADebitAvailableForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
|
||||
return donateToSignalType != DonateToSignalType.GIFT && isSEPADebitAvailable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user is in a region which suports IDEAL transfers, based off local phone number and
|
||||
* donation type
|
||||
*/
|
||||
fun isIDEALAvailbleForDonateToSignalType(donateToSignalType: DonateToSignalType): Boolean {
|
||||
return donateToSignalType != DonateToSignalType.GIFT && isIDEALAvailable()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
@@ -147,7 +149,10 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
|
||||
fun setSubscriptionLevel(gatewayRequest: GatewayRequest, isLongRunning: Boolean): Completable {
|
||||
val subscriptionLevel = gatewayRequest.level.toString()
|
||||
val uiSessionKey = gatewayRequest.uiSessionKey
|
||||
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
@@ -186,13 +191,24 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
|
||||
val terminalDonation = TerminalDonationQueue.TerminalDonation(
|
||||
level = gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = isLongRunning
|
||||
)
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey, terminalDonation).enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
DonationError.donationPending(DonationErrorSource.MONTHLY, gatewayRequest)
|
||||
} else {
|
||||
DonationError.timeoutWaitingForToken(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
@@ -202,20 +218,20 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Subscription request response job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION))
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY))
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "Subscription request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Subscription request response job timed out.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Subscription request response interrupted.", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.SUBSCRIPTION))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
}.doOnError {
|
||||
@@ -224,22 +240,28 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
|
||||
private fun getOrCreateLevelUpdateOperation(subscriptionLevel: String): Single<LevelUpdateOperation> = Single.fromCallable {
|
||||
Log.d(TAG, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
|
||||
if (levelUpdateOperation == null) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
getOrCreateLevelUpdateOperation(TAG, subscriptionLevel)
|
||||
}
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Created a new operation for $subscriptionLevel")
|
||||
newOperation
|
||||
} else {
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(TAG, "Reusing operation for $subscriptionLevel")
|
||||
levelUpdateOperation
|
||||
companion object {
|
||||
fun getOrCreateLevelUpdateOperation(tag: String, subscriptionLevel: String): LevelUpdateOperation {
|
||||
Log.d(tag, "Retrieving level update operation for $subscriptionLevel")
|
||||
val levelUpdateOperation = SignalStore.donationsValues().getLevelOperation(subscriptionLevel)
|
||||
return if (levelUpdateOperation == null) {
|
||||
val newOperation = LevelUpdateOperation(
|
||||
idempotencyKey = IdempotencyKey.generate(),
|
||||
level = subscriptionLevel
|
||||
)
|
||||
|
||||
SignalStore.donationsValues().setLevelOperation(newOperation)
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(tag, "Created a new operation for $subscriptionLevel")
|
||||
newOperation
|
||||
} else {
|
||||
LevelUpdate.updateProcessingState(true)
|
||||
Log.d(tag, "Reusing operation for $subscriptionLevel")
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,17 @@ import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
@@ -38,7 +41,7 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
Single.error(throwable)
|
||||
} else {
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
Single.error(DonationError.getPaymentSetupError(errorSource, throwable, paymentSourceType))
|
||||
}
|
||||
}
|
||||
@@ -106,22 +109,20 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
|
||||
fun waitForOneTimeRedemption(
|
||||
price: FiatMoney,
|
||||
gatewayRequest: GatewayRequest,
|
||||
paymentIntentId: String,
|
||||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long,
|
||||
donationProcessor: DonationProcessor,
|
||||
uiSessionKey: Long
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
val isLongRunning = paymentSourceType == PaymentSourceType.Stripe.SEPADebit
|
||||
val isBoost = gatewayRequest.recipientId == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
val waitOnRedemption = Completable.create {
|
||||
val donationReceiptRecord = if (isBoost) {
|
||||
DonationReceiptRecord.createForBoost(price)
|
||||
DonationReceiptRecord.createForBoost(gatewayRequest.fiat)
|
||||
} else {
|
||||
DonationReceiptRecord.createForGift(price)
|
||||
DonationReceiptRecord.createForGift(gatewayRequest.fiat)
|
||||
}
|
||||
|
||||
val donationTypeLabel = donationReceiptRecord.type.code.replaceFirstChar { c -> if (c.isLowerCase()) c.titlecase(Locale.US) else c.toString() }
|
||||
@@ -129,12 +130,25 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
Log.d(TAG, "Confirmed payment intent. Recording $donationTypeLabel receipt and submitting badge reimbursement job chain.", true)
|
||||
SignalDatabase.donationReceipts.addReceipt(donationReceiptRecord)
|
||||
|
||||
SignalStore.donationsValues().setPendingOneTimeDonation(
|
||||
DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
gatewayRequest.badge,
|
||||
paymentSourceType,
|
||||
gatewayRequest.fiat
|
||||
)
|
||||
)
|
||||
|
||||
val terminalDonation = TerminalDonationQueue.TerminalDonation(
|
||||
level = gatewayRequest.level,
|
||||
isLongRunningPaymentMethod = isLongRunning
|
||||
)
|
||||
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, gatewayRequest.recipientId, gatewayRequest.additionalMessage, gatewayRequest.level, donationProcessor, gatewayRequest.uiSessionKey, terminalDonation)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
@@ -144,6 +158,12 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
}
|
||||
|
||||
val timeoutError: DonationError = if (isLongRunning) {
|
||||
DonationError.donationPending(donationErrorSource, gatewayRequest)
|
||||
} else {
|
||||
DonationError.timeoutWaitingForToken(donationErrorSource)
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
@@ -157,16 +177,16 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
else -> {
|
||||
Log.d(TAG, "$donationTypeLabel request response job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "$donationTypeLabel job chain timed out waiting for job completion.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.d(TAG, "$donationTypeLabel job chain interrupted", e, true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(donationErrorSource))
|
||||
it.onError(timeoutError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
@@ -47,7 +46,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
@@ -101,7 +100,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
.flatMap { result ->
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
val errorSource = if (recipient.isSelf) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Created payment intent for $price.", true)
|
||||
when (result) {
|
||||
@@ -131,7 +130,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
badgeRecipient: RecipientId
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.ONE_TIME else DonationErrorSource.GIFT
|
||||
|
||||
Log.d(TAG, "Confirming payment intent...", true)
|
||||
return stripeApi.confirmPaymentIntent(paymentSource, paymentIntent)
|
||||
@@ -203,19 +202,22 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
* that we are successful and proceed as normal. If the payment didn't actually succeed, then we
|
||||
* expect an error later in the chain to inform us of this.
|
||||
*/
|
||||
fun getStatusAndPaymentMethodId(stripeIntentAccessor: StripeIntentAccessor): Single<StatusAndPaymentMethodId> {
|
||||
fun getStatusAndPaymentMethodId(
|
||||
stripeIntentAccessor: StripeIntentAccessor,
|
||||
paymentMethodId: String?
|
||||
): Single<StatusAndPaymentMethodId> {
|
||||
return Single.fromCallable {
|
||||
when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(StripeIntentStatus.SUCCEEDED, null)
|
||||
StripeIntentAccessor.ObjectType.NONE -> StatusAndPaymentMethodId(stripeIntentAccessor.intentId, StripeIntentStatus.SUCCEEDED, paymentMethodId)
|
||||
StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> stripeApi.getPaymentIntent(stripeIntentAccessor).let {
|
||||
if (it.status == null) {
|
||||
Log.d(TAG, "Returned payment intent had a null status.", true)
|
||||
}
|
||||
StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
|
||||
}
|
||||
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
StatusAndPaymentMethodId(stripeIntentAccessor.intentId, it.status, it.paymentMethod)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +225,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
|
||||
fun setDefaultPaymentMethod(
|
||||
paymentMethodId: String,
|
||||
setupIntentId: String,
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Completable {
|
||||
return Single.fromCallable {
|
||||
@@ -231,9 +234,15 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}.flatMap {
|
||||
Log.d(TAG, "Setting default payment method via Signal service...")
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
|
||||
if (paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultIdealPaymentMethod(it.subscriberId, setupIntentId)
|
||||
} else {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.setDefaultStripePaymentMethod(it.subscriberId, paymentMethodId)
|
||||
}
|
||||
}
|
||||
}.flatMap(ServiceResponse<EmptyResponse>::flattenResult).ignoreElement().doOnComplete {
|
||||
Log.d(TAG, "Set default payment method via Signal service!")
|
||||
@@ -257,7 +266,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
|
||||
}
|
||||
|
||||
fun createIdealPaymentSource(idealData: StripeApi.IDEALData): Single<StripeApi.PaymentSource> {
|
||||
Log.d(TAG, "Creating iDEAL payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromIDEALData(idealData)
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val intentId: String,
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
)
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
|
||||
|
||||
import android.content.DialogInterface
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Bottom Sheet displayed when the app notices that a long-running donation has
|
||||
* completed.
|
||||
*/
|
||||
class TerminalDonationBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
|
||||
private const val ARG_DONATION_COMPLETED = "arg.donation.completed"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, terminalDonation: TerminalDonationQueue.TerminalDonation) {
|
||||
TerminalDonationBottomSheet().apply {
|
||||
arguments = bundleOf(
|
||||
ARG_DONATION_COMPLETED to terminalDonation.encode()
|
||||
)
|
||||
|
||||
show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
private val terminalDonation: TerminalDonationQueue.TerminalDonation by lazy(LazyThreadSafetyMode.NONE) {
|
||||
TerminalDonationQueue.TerminalDonation.ADAPTER.decode(requireArguments().getByteArray(ARG_DONATION_COMPLETED)!!)
|
||||
}
|
||||
|
||||
private val viewModel: TerminalDonationViewModel by viewModel {
|
||||
TerminalDonationViewModel(terminalDonation, badgeRepository = BadgeRepository(requireContext()))
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
if (terminalDonation.error != null) {
|
||||
PaymentFailureBottomSheet()
|
||||
} else {
|
||||
CompletedSheet()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PaymentFailureBottomSheet() {
|
||||
val badge by viewModel.badge
|
||||
|
||||
DonationPaymentFailureBottomSheet(
|
||||
badge = badge,
|
||||
onTryAgainClick = {
|
||||
startActivity(AppSettingsActivity.manageSubscriptions(requireContext()))
|
||||
},
|
||||
onNotNowClick = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompletedSheet() {
|
||||
val badge by viewModel.badge
|
||||
val isToggleChecked by viewModel.isToggleChecked
|
||||
val toggleType by viewModel.toggleType
|
||||
|
||||
DonationCompletedSheetContent(
|
||||
badge = badge,
|
||||
isToggleChecked = isToggleChecked,
|
||||
toggleType = toggleType,
|
||||
onCheckChanged = viewModel::onToggleCheckChanged,
|
||||
onDoneClick = { dismissAllowingStateLoss() }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
|
||||
viewModel.commitToggleState()
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DonationPaymentFailureBottomSheet() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
DonationPaymentFailureBottomSheet(
|
||||
badge = null,
|
||||
onTryAgainClick = {},
|
||||
onNotNowClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DonationPaymentFailureBottomSheet(
|
||||
badge: Badge?,
|
||||
onTryAgainClick: () -> Unit,
|
||||
onNotNowClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 21.dp, bottom = 16.dp)
|
||||
) {
|
||||
BadgeImage112(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.padding(2.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
shape = CircleShape
|
||||
)
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_error_circle_fill_24),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationErrorBottomSheet__donation_couldnt_be_processed),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 45.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationErrorBottomSheet__were_having_trouble),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 24.dp)
|
||||
.padding(horizontal = 45.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onTryAgainClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(top = 32.dp, bottom = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationErrorBottomSheet__try_again)
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNotNowClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationErrorBottomSheet__not_now)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun DonationCompletedSheetContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
DonationCompletedSheetContent(
|
||||
badge = null,
|
||||
isToggleChecked = false,
|
||||
toggleType = TerminalDonationViewModel.ToggleType.NONE,
|
||||
onCheckChanged = {},
|
||||
onDoneClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DonationCompletedSheetContent(
|
||||
badge: Badge?,
|
||||
isToggleChecked: Boolean,
|
||||
toggleType: TerminalDonationViewModel.ToggleType,
|
||||
onCheckChanged: (Boolean) -> Unit,
|
||||
onDoneClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
BadgeImage112(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.padding(top = 21.dp, bottom = 16.dp)
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationCompletedBottomSheet__donation_complete),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 45.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationCompleteBottomSheet__your_bank_transfer_was_received),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 24.dp)
|
||||
.padding(horizontal = 45.dp)
|
||||
)
|
||||
|
||||
if (toggleType == TerminalDonationViewModel.ToggleType.NONE) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
DonationToggleRow(
|
||||
checked = isToggleChecked,
|
||||
text = stringResource(id = toggleType.copyId),
|
||||
onCheckChanged = onCheckChanged
|
||||
)
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onDoneClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(top = 48.dp, bottom = 56.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.DonationPendingBottomSheet__done)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DonationToggleRow(
|
||||
checked: Boolean,
|
||||
text: String,
|
||||
onCheckChanged: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckChanged,
|
||||
modifier = Modifier.align(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* Handles displaying the "Thank You" or "Donation completed" sheet when the user navigates to an appropriate screen.
|
||||
* These sheets are one-shot.
|
||||
*/
|
||||
class TerminalDonationDelegate(
|
||||
private val fragmentManager: FragmentManager,
|
||||
private val lifecycleOwner: LifecycleOwner
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable().apply {
|
||||
bindTo(lifecycleOwner)
|
||||
}
|
||||
|
||||
private val badgeRepository = TerminalDonationRepository()
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
val donations = SignalStore.donationsValues().consumeTerminalDonations()
|
||||
for (donation in donations) {
|
||||
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
|
||||
TerminalDonationBottomSheet.show(fragmentManager, donation)
|
||||
} else {
|
||||
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
|
||||
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
|
||||
|
||||
sheet.arguments = args
|
||||
sheet.show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import java.util.Locale
|
||||
|
||||
class TerminalDonationRepository(
|
||||
private val donationsService: DonationsService = ApplicationDependencies.getDonationsService()
|
||||
) {
|
||||
fun getBadge(terminalDonation: TerminalDonationQueue.TerminalDonation): Single<Badge> {
|
||||
return Single
|
||||
.fromCallable { donationsService.getDonationsConfiguration(Locale.getDefault()) }
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { it.levels[terminalDonation.level.toInt()]!! }
|
||||
.map { Badges.fromServiceBadge(it.badge) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.completed
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.TerminalDonationQueue
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
class TerminalDonationViewModel(
|
||||
donationCompleted: TerminalDonationQueue.TerminalDonation,
|
||||
repository: TerminalDonationRepository = TerminalDonationRepository(),
|
||||
private val badgeRepository: BadgeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(TerminalDonationViewModel::class.java)
|
||||
}
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val internalBadge = mutableStateOf<Badge?>(null)
|
||||
private val internalToggleChecked = mutableStateOf(false)
|
||||
private val internalToggleType = mutableStateOf(ToggleType.NONE)
|
||||
|
||||
val badge: State<Badge?> = internalBadge
|
||||
val isToggleChecked: State<Boolean> = internalToggleChecked
|
||||
val toggleType: State<ToggleType> = internalToggleType
|
||||
|
||||
init {
|
||||
disposables += repository.getBadge(donationCompleted)
|
||||
.map { badge ->
|
||||
val hasOtherBadges = Recipient.self().badges.filterNot { it.id == badge.id }.isNotEmpty()
|
||||
val isDisplayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
|
||||
|
||||
val toggleType = when {
|
||||
hasOtherBadges && isDisplayingBadges -> ToggleType.MAKE_FEATURED_BADGE
|
||||
else -> ToggleType.DISPLAY_ON_PROFILE
|
||||
}
|
||||
|
||||
badge to toggleType
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
onSuccess = { (badge, toggleType) ->
|
||||
internalBadge.value = badge
|
||||
internalToggleType.value = toggleType
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun onToggleCheckChanged(isChecked: Boolean) {
|
||||
internalToggleChecked.value = isChecked
|
||||
}
|
||||
|
||||
/**
|
||||
* Note that the intention here is that these are able to complete outside of the scope of the ViewModel's lifecycle.
|
||||
*/
|
||||
@SuppressLint("CheckResult")
|
||||
fun commitToggleState() {
|
||||
when (toggleType.value) {
|
||||
ToggleType.NONE -> Unit
|
||||
ToggleType.MAKE_FEATURED_BADGE -> {
|
||||
badgeRepository.setVisibilityForAllBadges(isToggleChecked.value).subscribeBy(
|
||||
onError = {
|
||||
Log.w(TAG, "Failure while updating badge visibility", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ToggleType.DISPLAY_ON_PROFILE -> {
|
||||
val badge = this.badge.value
|
||||
if (badge == null) {
|
||||
Log.w(TAG, "No badge!")
|
||||
return
|
||||
}
|
||||
|
||||
badgeRepository.setFeaturedBadge(badge).subscribeBy(
|
||||
onError = {
|
||||
Log.w(TAG, "Failure while updating featured badge", it)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
enum class ToggleType(@StringRes val copyId: Int) {
|
||||
NONE(-1),
|
||||
MAKE_FEATURED_BADGE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge),
|
||||
DISPLAY_ON_PROFILE(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile)
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,5 @@ sealed class DonateToSignalAction {
|
||||
data class DisplayCurrencySelectionDialog(val donateToSignalType: DonateToSignalType, val supportedCurrencies: List<String>) : DonateToSignalAction()
|
||||
data class DisplayGatewaySelectorDialog(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
|
||||
data class CancelSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
|
||||
data class UpdateSubscription(val gatewayRequest: GatewayRequest) : DonateToSignalAction()
|
||||
data class UpdateSubscription(val gatewayRequest: GatewayRequest, val isLongRunning: Boolean) : DonateToSignalAction()
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
@@ -40,6 +41,7 @@ import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
@@ -106,7 +108,7 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.ONE_TIME, DonationErrorSource.MONTHLY)
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
@@ -141,12 +143,14 @@ class DonateToSignalFragment :
|
||||
|
||||
findNavController().safeNavigate(navAction)
|
||||
}
|
||||
|
||||
is DonateToSignalAction.DisplayGatewaySelectorDialog -> {
|
||||
Log.d(TAG, "Presenting gateway selector for ${action.gatewayRequest}")
|
||||
val navAction = DonateToSignalFragmentDirections.actionDonateToSignalFragmentToGatewaySelectorBottomSheetDialog(action.gatewayRequest)
|
||||
|
||||
findNavController().safeNavigate(navAction)
|
||||
}
|
||||
|
||||
is DonateToSignalAction.CancelSubscription -> {
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
@@ -155,6 +159,7 @@ class DonateToSignalFragment :
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
is DonateToSignalAction.UpdateSubscription -> {
|
||||
findNavController().safeNavigate(
|
||||
DonateToSignalFragmentDirections.actionDonateToSignalFragmentToStripePaymentInProgressFragment(
|
||||
@@ -229,7 +234,6 @@ class DonateToSignalFragment :
|
||||
|
||||
customPref(
|
||||
DonationPillToggle.Model(
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
selected = state.donateToSignalType,
|
||||
onClick = {
|
||||
viewModel.toggleDonationType()
|
||||
@@ -252,23 +256,27 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
isEnabled = state.canUpdate,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
|
||||
FiatMoneyUtil.format(
|
||||
requireContext().resources,
|
||||
viewModel.getSelectedSubscriptionCost(),
|
||||
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
|
||||
FiatMoneyUtil.format(
|
||||
requireContext().resources,
|
||||
viewModel.getSelectedSubscriptionCost(),
|
||||
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -278,28 +286,58 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
|
||||
.show()
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.DonateToSignalFragment__continue),
|
||||
isEnabled = state.canContinue,
|
||||
isEnabled = state.continueEnabled,
|
||||
onClick = {
|
||||
viewModel.requestSelectGateway()
|
||||
if (state.canContinue) {
|
||||
viewModel.requestSelectGateway()
|
||||
} else {
|
||||
showDonationPendingDialog(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDonationPendingDialog(state: DonateToSignalState) {
|
||||
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
|
||||
}
|
||||
} else {
|
||||
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonateToSignalFragment__you_have_a_donation_pending)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.displayOneTimeSelection(areFieldsEnabled: Boolean, state: DonateToSignalState.OneTimeDonationState) {
|
||||
when (state.donationStage) {
|
||||
DonateToSignalState.DonationStage.INIT -> customPref(Boost.LoadingModel())
|
||||
@@ -333,11 +371,6 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.displayMonthlySelection(areFieldsEnabled: Boolean, state: DonateToSignalState.MonthlyDonationState) {
|
||||
if (state.transactionState.isTransactionJobPending) {
|
||||
customPref(Subscription.LoaderModel())
|
||||
return
|
||||
}
|
||||
|
||||
when (state.donationStage) {
|
||||
DonateToSignalState.DonationStage.INIT -> customPref(Subscription.LoaderModel())
|
||||
DonateToSignalState.DonationStage.FAILURE -> customPref(NetworkFailure.Model { viewModel.retryMonthlyDonationState() })
|
||||
@@ -417,8 +450,12 @@ class DonateToSignalFragment :
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
override fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToIdealTransferDetailsFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayResponse))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
@@ -432,4 +469,8 @@ class DonateToSignalFragment :
|
||||
override fun onUserCancelledPaymentFlow() {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.database.model.isLongRunning
|
||||
import org.thoughtcrime.securesms.database.model.isPending
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
@@ -20,7 +22,7 @@ data class DonateToSignalState(
|
||||
val areFieldsEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
@@ -33,7 +35,7 @@ data class DonateToSignalState(
|
||||
|
||||
val canSetCurrency: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && !monthlyDonationState.isSubscriptionActive
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
@@ -59,13 +61,20 @@ data class DonateToSignalState(
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
val continueEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> continueEnabled && !monthlyDonationState.isSubscriptionActive
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canUpdate: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> false
|
||||
@@ -73,6 +82,9 @@ data class DonateToSignalState(
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val isUpdateLongRunning: Boolean
|
||||
get() = monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT
|
||||
|
||||
data class OneTimeDonationState(
|
||||
val badge: Badge? = null,
|
||||
val selectedCurrency: Currency = SignalStore.donationsValues().getOneTimeCurrency(),
|
||||
@@ -82,6 +94,8 @@ data class DonateToSignalState(
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val donationStage: DonationStage = DonationStage.INIT,
|
||||
val selectableCurrencyCodes: List<String> = emptyList(),
|
||||
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isPending(),
|
||||
val isOneTimeDonationLongRunning: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation().isLongRunning(),
|
||||
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
|
||||
) {
|
||||
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)
|
||||
|
||||
@@ -12,8 +12,8 @@ enum class DonateToSignalType(val requestCode: Short) : Parcelable {
|
||||
|
||||
fun toErrorSource(): DonationErrorSource {
|
||||
return when (this) {
|
||||
ONE_TIME -> DonationErrorSource.BOOST
|
||||
MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
MONTHLY -> DonationErrorSource.MONTHLY
|
||||
GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDo
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.SubscriptionRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.database.model.isExpired
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -106,7 +107,7 @@ class DonateToSignalViewModel(
|
||||
fun updateSubscription() {
|
||||
val snapshot = store.state
|
||||
if (snapshot.areFieldsEnabled) {
|
||||
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot)))
|
||||
_actions.onNext(DonateToSignalAction.UpdateSubscription(createGatewayRequest(snapshot), snapshot.isUpdateLongRunning))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +208,26 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
|
||||
val isOneTimeDonationInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
|
||||
it.map { jobState ->
|
||||
when (jobState) {
|
||||
JobTracker.JobState.PENDING -> true
|
||||
JobTracker.JobState.RUNNING -> true
|
||||
else -> false
|
||||
}
|
||||
}.orElse(false)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.map { pending -> pending.filter { !it.isExpired }.isPresent }
|
||||
.distinctUntilChanged()
|
||||
|
||||
oneTimeDonationDisposables += Observable
|
||||
.combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b }
|
||||
.subscribe { hasPendingOneTimeDonation ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) }
|
||||
}
|
||||
|
||||
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
|
||||
onSuccess = { badge ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(badge = badge)) }
|
||||
@@ -274,7 +295,7 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun monitorLevelUpdateProcessing() {
|
||||
val isTransactionJobInProgress: Observable<Boolean> = SubscriptionRedemptionJobWatcher.watch().map {
|
||||
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
|
||||
it.map { jobState ->
|
||||
when (jobState) {
|
||||
JobTracker.JobState.PENDING -> true
|
||||
|
||||
@@ -30,10 +30,12 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.ga
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.paypal.PayPalPaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.util.Currency
|
||||
|
||||
@@ -89,6 +91,16 @@ class DonationCheckoutDelegate(
|
||||
handleDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(BankTransferRequestKeys.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
handleDonationProcessorActionResult(result)
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(BankTransferRequestKeys.PENDING_KEY) { _, bundle ->
|
||||
val request: GatewayRequest = bundle.getParcelableCompat(BankTransferRequestKeys.PENDING_KEY, GatewayRequest::class.java)!!
|
||||
callback.navigateToDonationPending(gatewayRequest = request)
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(PayPalPaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(PayPalPaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
handleDonationProcessorActionResult(result)
|
||||
@@ -101,7 +113,8 @@ class DonationCheckoutDelegate(
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
|
||||
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> launchBankTransfer(gatewayResponse)
|
||||
GatewayResponse.Gateway.IDEAL -> launchBankTransfer(gatewayResponse)
|
||||
}
|
||||
} else {
|
||||
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
|
||||
@@ -121,6 +134,7 @@ class DonationCheckoutDelegate(
|
||||
if (result.action == DonationProcessorAction.CANCEL_SUBSCRIPTION) {
|
||||
Snackbar.make(fragment.requireView(), R.string.SubscribeFragment__your_subscription_has_been_cancelled, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
SignalStore.donationsValues().removeTerminalDonation(result.request.level)
|
||||
callback.onPaymentComplete(result.request)
|
||||
}
|
||||
}
|
||||
@@ -156,8 +170,12 @@ class DonationCheckoutDelegate(
|
||||
callback.navigateToCreditCardForm(gatewayResponse.request)
|
||||
}
|
||||
|
||||
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
|
||||
callback.navigateToBankTransferMandate(gatewayResponse.request)
|
||||
private fun launchBankTransfer(gatewayResponse: GatewayResponse) {
|
||||
if (gatewayResponse.request.donateToSignalType != DonateToSignalType.MONTHLY && gatewayResponse.gateway == GatewayResponse.Gateway.IDEAL) {
|
||||
callback.navigateToIdealDetailsFragment(gatewayResponse.request)
|
||||
} else {
|
||||
callback.navigateToBankTransferMandate(gatewayResponse)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerGooglePayCallback() {
|
||||
@@ -188,8 +206,8 @@ class DonationCheckoutDelegate(
|
||||
|
||||
val error = DonationError.getGooglePayRequestTokenError(
|
||||
source = when (request.donateToSignalType) {
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
},
|
||||
throwable = googlePayException
|
||||
@@ -210,11 +228,11 @@ class DonationCheckoutDelegate(
|
||||
|
||||
private var fragment: Fragment? = null
|
||||
private var errorDialog: DialogInterface? = null
|
||||
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
|
||||
private var errorHandlerCallback: ErrorHandlerCallback? = null
|
||||
|
||||
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
fun attach(fragment: Fragment, errorHandlerCallback: ErrorHandlerCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
this.fragment = fragment
|
||||
this.userCancelledFlowCallback = userCancelledFlowCallback
|
||||
this.errorHandlerCallback = errorHandlerCallback
|
||||
|
||||
val disposables = LifecycleDisposable()
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
@@ -231,7 +249,7 @@ class DonationCheckoutDelegate(
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
errorDialog?.dismiss()
|
||||
fragment = null
|
||||
userCancelledFlowCallback = null
|
||||
errorHandlerCallback = null
|
||||
}
|
||||
|
||||
private fun registerErrorSource(errorSource: DonationErrorSource): Disposable {
|
||||
@@ -262,25 +280,47 @@ class DonationCheckoutDelegate(
|
||||
return
|
||||
}
|
||||
|
||||
if (throwable is DonationError.UserLaunchedExternalApplication) {
|
||||
Log.d(TAG, "User launched an external application.", true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (throwable is DonationError.BadgeRedemptionError.DonationPending) {
|
||||
Log.d(TAG, "Long-running donation is still pending.", true)
|
||||
errorHandlerCallback?.navigateToDonationPending(throwable.gatewayRequest)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Displaying donation error dialog.", true)
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
fragment!!.requireContext(),
|
||||
throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
var tryCCAgain = false
|
||||
var tryAgain = false
|
||||
|
||||
override fun onTryCreditCardAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryCCAgain = true
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTryBankTransferAgain(context: Context): DonationErrorParams.ErrorAction<Unit> {
|
||||
return DonationErrorParams.ErrorAction(
|
||||
label = R.string.DeclineCode__try,
|
||||
action = {
|
||||
tryAgain = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onDialogDismissed() {
|
||||
errorDialog = null
|
||||
if (!tryCCAgain) {
|
||||
if (!tryAgain) {
|
||||
tryAgain = false
|
||||
fragment!!.findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
@@ -289,15 +329,17 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
interface UserCancelledFlowCallback {
|
||||
interface ErrorHandlerCallback {
|
||||
fun onUserCancelledPaymentFlow()
|
||||
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
|
||||
}
|
||||
|
||||
interface Callback : UserCancelledFlowCallback {
|
||||
interface Callback : ErrorHandlerCallback {
|
||||
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
|
||||
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
|
||||
fun navigateToIdealDetailsFragment(gatewayRequest: GatewayRequest)
|
||||
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
|
||||
fun onPaymentComplete(gatewayRequest: GatewayRequest)
|
||||
fun onProcessorActionProcessed()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.c
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -18,6 +17,7 @@ import androidx.navigation.navGraphViewModels
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
@@ -29,7 +29,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.st
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.databinding.CreditCardFragmentBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -48,9 +47,11 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
@@ -64,13 +65,13 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
}
|
||||
}
|
||||
|
||||
binding.title.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
binding.continueButton.text = if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
getString(
|
||||
R.string.CreditCardFragment__donation_amount_s_per_month,
|
||||
R.string.CreditCardFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(R.string.CreditCardFragment__donation_amount_s, FiatMoneyUtil.format(resources, args.request.fiat))
|
||||
getString(R.string.CreditCardFragment__donate_s, FiatMoneyUtil.format(resources, args.request.fiat))
|
||||
}
|
||||
|
||||
binding.description.setLinkColor(ContextCompat.getColor(requireContext(), R.color.signal_colorPrimary))
|
||||
@@ -140,13 +141,6 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
|
||||
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
@@ -158,13 +152,6 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!TextSecurePreferences.isScreenSecurityEnabled(requireContext())) {
|
||||
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentContinue(state: CreditCardValidationState) {
|
||||
binding.continueButton.isEnabled = state.isValid
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
sealed interface GatewayOrderStrategy {
|
||||
|
||||
val orderedGateways: Set<GatewayResponse.Gateway>
|
||||
|
||||
private object Default : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT,
|
||||
GatewayResponse.Gateway.IDEAL
|
||||
)
|
||||
}
|
||||
|
||||
private object NorthAmerica : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT,
|
||||
GatewayResponse.Gateway.IDEAL
|
||||
)
|
||||
}
|
||||
|
||||
private object Netherlands : GatewayOrderStrategy {
|
||||
override val orderedGateways: Set<GatewayResponse.Gateway> = setOf(
|
||||
GatewayResponse.Gateway.IDEAL,
|
||||
GatewayResponse.Gateway.PAYPAL,
|
||||
GatewayResponse.Gateway.GOOGLE_PAY,
|
||||
GatewayResponse.Gateway.CREDIT_CARD,
|
||||
GatewayResponse.Gateway.SEPA_DEBIT
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getStrategy(): GatewayOrderStrategy {
|
||||
val self = Recipient.self()
|
||||
val e164 = self.e164.orNull() ?: return Default
|
||||
|
||||
return if (PhoneNumberUtil.getInstance().parse(e164, "").countryCode == 1) {
|
||||
NorthAmerica
|
||||
} else if (InAppDonations.isIDEALAvailable()) {
|
||||
Netherlands
|
||||
} else {
|
||||
Default
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
|
||||
GOOGLE_PAY,
|
||||
PAYPAL,
|
||||
CREDIT_CARD,
|
||||
SEPA_DEBIT;
|
||||
SEPA_DEBIT,
|
||||
IDEAL;
|
||||
|
||||
fun toPaymentSourceType(): PaymentSourceType {
|
||||
return when (this) {
|
||||
@@ -18,6 +19,7 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
|
||||
PAYPAL -> PaymentSourceType.PayPal
|
||||
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
|
||||
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
|
||||
IDEAL -> PaymentSourceType.Stripe.IDEAL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,66 +73,113 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
return@configure
|
||||
}
|
||||
|
||||
if (state.isGooglePayAvailable) {
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isPayPalAvailable) {
|
||||
space(8.dp)
|
||||
|
||||
customPref(
|
||||
PayPalButton.Model(
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
},
|
||||
isEnabled = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isCreditCardAvailable) {
|
||||
space(8.dp)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT),
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isSEPADebitAvailable) {
|
||||
space(8.dp)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
|
||||
val isFirst = index == 0
|
||||
when (gateway) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state, isFirst)
|
||||
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state, isFirst)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state, isFirst)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state, isFirst)
|
||||
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state, isFirst)
|
||||
}
|
||||
}
|
||||
|
||||
space(16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
if (state.isGooglePayAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
isEnabled = true,
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.GOOGLE_PAY, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
if (state.isPayPalAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
customPref(
|
||||
PayPalButton.Model(
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.PAYPAL, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
},
|
||||
isEnabled = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
if (state.isCreditCardAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.CREDIT_CARD, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
if (state.isSEPADebitAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
if (state.isIDEALAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.IDEAL, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "payment_checkout_mode"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
|
||||
import java.util.Locale
|
||||
|
||||
class GatewaySelectorRepository(
|
||||
@@ -15,9 +16,10 @@ class GatewaySelectorRepository(
|
||||
.map { configuration ->
|
||||
configuration.getAvailablePaymentMethods(currencyCode).map {
|
||||
when (it) {
|
||||
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
DonationsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
DonationsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
DonationsConfiguration.SEPA_DEBIT -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
DonationsConfiguration.IDEAL -> listOf(GatewayResponse.Gateway.IDEAL)
|
||||
else -> listOf()
|
||||
}
|
||||
}.flatten().toSet()
|
||||
|
||||
@@ -3,10 +3,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.g
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class GatewaySelectorState(
|
||||
val gatewayOrderStrategy: GatewayOrderStrategy,
|
||||
val loading: Boolean = true,
|
||||
val badge: Badge,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val isPayPalAvailable: Boolean = false,
|
||||
val isCreditCardAvailable: Boolean = false,
|
||||
val isSEPADebitAvailable: Boolean = false
|
||||
val isSEPADebitAvailable: Boolean = false,
|
||||
val isIDEALAvailable: Boolean = false
|
||||
)
|
||||
|
||||
@@ -21,11 +21,13 @@ class GatewaySelectorViewModel(
|
||||
|
||||
private val store = RxStore(
|
||||
GatewaySelectorState(
|
||||
gatewayOrderStrategy = GatewayOrderStrategy.getStrategy(),
|
||||
badge = args.request.badge,
|
||||
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
|
||||
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType),
|
||||
isIDEALAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.IDEAL, args.request.donateToSignalType)
|
||||
)
|
||||
)
|
||||
private val disposables = CompositeDisposable()
|
||||
@@ -43,7 +45,8 @@ class GatewaySelectorViewModel(
|
||||
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
|
||||
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT),
|
||||
isIDEALAvailable = it.isIDEALAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.IDEAL)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
else -> error("Unsupported action: ${args.action}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +122,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
|
||||
private fun routeToOneTimeConfirmation(createPaymentIntentResponse: PayPalCreatePaymentIntentResponse): Single<PayPalConfirmationResult> {
|
||||
return Single.create<PayPalConfirmationResult> { emitter ->
|
||||
return Single.create { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: PayPalConfirmationResult? = bundle.getParcelableCompat(PayPalConfirmationDialogFragment.REQUEST_KEY, PayPalConfirmationResult::class.java)
|
||||
if (result != null) {
|
||||
@@ -149,7 +150,7 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
|
||||
private fun routeToMonthlyConfirmation(createPaymentIntentResponse: PayPalCreatePaymentMethodResponse): Single<PayPalPaymentMethodId> {
|
||||
return Single.create<PayPalPaymentMethodId> { emitter ->
|
||||
return Single.create { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: Boolean = bundle.getBoolean(PayPalConfirmationDialogFragment.REQUEST_KEY)
|
||||
if (result) {
|
||||
@@ -175,31 +176,4 @@ class PayPalPaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun <T : Any> displayCompleteOrderSheet(confirmationData: T): Single<T> {
|
||||
return Single.create<T> { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: Boolean = bundle.getBoolean(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
|
||||
if (result) {
|
||||
Log.d(TAG, "User confirmed order. Continuing...")
|
||||
emitter.onSuccess(confirmationData)
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
|
||||
parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
|
||||
parentFragmentManager.setFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY, this, listener)
|
||||
|
||||
findNavController().safeNavigate(
|
||||
PayPalPaymentInProgressFragmentDirections.actionPaypalPaymentInProgressFragmentToPaypalCompleteOrderBottomSheet(args.request)
|
||||
)
|
||||
|
||||
emitter.setCancellable {
|
||||
Log.d(TAG, "Clearing complete order result listener.")
|
||||
parentFragmentManager.clearFragmentResult(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
|
||||
parentFragmentManager.clearFragmentResultListener(PayPalCompleteOrderBottomSheet.REQUEST_KEY)
|
||||
}
|
||||
}.subscribeOn(AndroidSchedulers.mainThread()).observeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class PayPalPaymentInProgressViewModel(
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -93,8 +93,8 @@ class PayPalPaymentInProgressViewModel(
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
@@ -153,13 +153,10 @@ class PayPalPaymentInProgressViewModel(
|
||||
}
|
||||
.flatMapCompletable { response ->
|
||||
oneTimeDonationRepository.waitForOneTimeRedemption(
|
||||
price = request.fiat,
|
||||
gatewayRequest = request,
|
||||
paymentIntentId = response.paymentId,
|
||||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.PAYPAL,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
paymentSourceType = PaymentSourceType.PayPal
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
@@ -190,9 +187,9 @@ class PayPalPaymentInProgressViewModel(
|
||||
.andThen(payPalRepository.createPaymentMethod())
|
||||
.flatMap(routeToPaypalConfirmation)
|
||||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, PaymentSourceType.PayPal)) }
|
||||
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request, false))
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
|
||||
@@ -200,8 +197,8 @@ class PayPalPaymentInProgressViewModel(
|
||||
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.donations.StripeApi
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Encapsulates the logic for navigating a user to a deeplink from within a webview or parsing out the fallback
|
||||
* or play store parameters to launch them into the market.
|
||||
*/
|
||||
object ExternalNavigationHelper {
|
||||
|
||||
fun maybeLaunchExternalNavigationIntent(context: Context, webRequestUri: Uri?, launchIntent: (Intent) -> Unit): Boolean {
|
||||
val url = webRequestUri ?: return false
|
||||
if (url.scheme?.startsWith("http") == true || url.scheme == StripeApi.RETURN_URL_SCHEME) {
|
||||
return false
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.ExternalNavigationHelper__leave_signal_to_confirm_payment)
|
||||
.setMessage(R.string.ExternalNavigationHelper__once_this_payment_is_confirmed)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ -> attemptIntentLaunch(context, url, launchIntent) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun attemptIntentLaunch(context: Context, url: Uri, launchIntent: (Intent) -> Unit) {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url)
|
||||
try {
|
||||
launchIntent(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
// Parses intent:// schema uris according to https://developer.chrome.com/docs/multidevice/android/intents/
|
||||
|
||||
if (url.scheme?.equals("intent") == true) {
|
||||
val fragmentParts: Map<String, String?> = url.fragment
|
||||
?.split(";")
|
||||
?.associate {
|
||||
val parts = it.split('=', limit = 2)
|
||||
|
||||
if (parts.size > 1) {
|
||||
parts[0] to parts[1]
|
||||
} else {
|
||||
parts[0] to null
|
||||
}
|
||||
} ?: emptyMap()
|
||||
|
||||
val fallbackUri = fragmentParts["S.browser_fallback_url"]?.let { Uri.parse(it) }
|
||||
|
||||
val packageId: String? = if (looksLikeAMarketLink(fallbackUri)) {
|
||||
fallbackUri!!.getQueryParameter("id")
|
||||
} else {
|
||||
fragmentParts["package"]
|
||||
}
|
||||
|
||||
if (!packageId.isNullOrBlank()) {
|
||||
try {
|
||||
launchIntent(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
Uri.parse("market://details?id=$packageId")
|
||||
)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
toastOnActivityNotFound(context)
|
||||
}
|
||||
} else if (fallbackUri != null) {
|
||||
try {
|
||||
launchIntent(
|
||||
Intent(
|
||||
Intent.ACTION_VIEW,
|
||||
fallbackUri
|
||||
)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
toastOnActivityNotFound(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun toastOnActivityNotFound(context: Context) {
|
||||
Toast.makeText(context, R.string.CommunicationActions_no_browser_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun looksLikeAMarketLink(uri: Uri?): Boolean {
|
||||
return uri != null && uri.host == "play.google.com" && uri.getQueryParameter("id") != null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toBigDecimal
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toDecimalValue
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ExternalLaunchTransactionState
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Encapsulates the data required to complete a pending external transaction
|
||||
*/
|
||||
@Parcelize
|
||||
data class Stripe3DSData(
|
||||
val stripeIntentAccessor: StripeIntentAccessor,
|
||||
val gatewayRequest: GatewayRequest,
|
||||
private val rawPaymentSourceType: String
|
||||
) : Parcelable {
|
||||
@IgnoredOnParcel
|
||||
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
|
||||
|
||||
fun toProtoBytes(): ByteArray {
|
||||
return ExternalLaunchTransactionState(
|
||||
stripeIntentAccessor = ExternalLaunchTransactionState.StripeIntentAccessor(
|
||||
type = when (stripeIntentAccessor.objectType) {
|
||||
StripeIntentAccessor.ObjectType.NONE, StripeIntentAccessor.ObjectType.PAYMENT_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT
|
||||
},
|
||||
intentId = stripeIntentAccessor.intentId,
|
||||
intentClientSecret = stripeIntentAccessor.intentClientSecret
|
||||
),
|
||||
gatewayRequest = ExternalLaunchTransactionState.GatewayRequest(
|
||||
donateToSignalType = when (gatewayRequest.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY
|
||||
DonateToSignalType.GIFT -> ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT
|
||||
},
|
||||
badge = Badges.toDatabaseBadge(gatewayRequest.badge),
|
||||
label = gatewayRequest.label,
|
||||
price = gatewayRequest.price.toDecimalValue(),
|
||||
currencyCode = gatewayRequest.currencyCode,
|
||||
level = gatewayRequest.level,
|
||||
recipient_id = gatewayRequest.recipientId.toLong(),
|
||||
additionalMessage = gatewayRequest.additionalMessage ?: ""
|
||||
),
|
||||
paymentSourceType = paymentSourceType.code
|
||||
).encode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromProtoBytes(byteArray: ByteArray, uiSessionKey: Long): Stripe3DSData {
|
||||
val proto = ExternalLaunchTransactionState.ADAPTER.decode(byteArray)
|
||||
return Stripe3DSData(
|
||||
stripeIntentAccessor = StripeIntentAccessor(
|
||||
objectType = when (proto.stripeIntentAccessor!!.type) {
|
||||
ExternalLaunchTransactionState.StripeIntentAccessor.Type.PAYMENT_INTENT -> StripeIntentAccessor.ObjectType.PAYMENT_INTENT
|
||||
ExternalLaunchTransactionState.StripeIntentAccessor.Type.SETUP_INTENT -> StripeIntentAccessor.ObjectType.SETUP_INTENT
|
||||
},
|
||||
intentId = proto.stripeIntentAccessor.intentId,
|
||||
intentClientSecret = proto.stripeIntentAccessor.intentClientSecret
|
||||
),
|
||||
gatewayRequest = GatewayRequest(
|
||||
uiSessionKey = uiSessionKey,
|
||||
donateToSignalType = when (proto.gatewayRequest!!.donateToSignalType) {
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.MONTHLY -> DonateToSignalType.MONTHLY
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.ONE_TIME -> DonateToSignalType.ONE_TIME
|
||||
ExternalLaunchTransactionState.GatewayRequest.DonateToSignalType.GIFT -> DonateToSignalType.GIFT
|
||||
},
|
||||
badge = Badges.fromDatabaseBadge(proto.gatewayRequest.badge!!),
|
||||
label = proto.gatewayRequest.label,
|
||||
price = proto.gatewayRequest.price!!.toBigDecimal(),
|
||||
currencyCode = proto.gatewayRequest.currencyCode,
|
||||
level = proto.gatewayRequest.level,
|
||||
recipientId = RecipientId.from(proto.gatewayRequest.recipient_id),
|
||||
additionalMessage = proto.gatewayRequest.additionalMessage.takeIf { it.isNotBlank() }
|
||||
),
|
||||
rawPaymentSourceType = proto.paymentSourceType
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate.s
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
@@ -18,6 +21,7 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
|
||||
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
@@ -27,6 +31,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "stripe_3ds_dialog_fragment"
|
||||
const val LAUNCHED_EXTERNAL = "stripe_3ds_dialog_fragment.pending"
|
||||
}
|
||||
|
||||
val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
|
||||
@@ -45,8 +50,14 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
dialog!!.window!!.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED
|
||||
)
|
||||
|
||||
binding.webView.webViewClient = Stripe3DSWebClient()
|
||||
binding.webView.settings.javaScriptEnabled = true
|
||||
binding.webView.settings.domStorageEnabled = true
|
||||
binding.webView.settings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
binding.webView.loadUrl(args.uri.toString())
|
||||
|
||||
@@ -66,8 +77,24 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
setFragmentResult(REQUEST_KEY, result ?: Bundle())
|
||||
}
|
||||
|
||||
private fun handleLaunchExternal(intent: Intent) {
|
||||
startActivity(intent)
|
||||
|
||||
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
|
||||
|
||||
result = bundleOf(
|
||||
LAUNCHED_EXTERNAL to true
|
||||
)
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
private inner class Stripe3DSWebClient : WebViewClient() {
|
||||
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
return ExternalNavigationHelper.maybeLaunchExternalNavigationIntent(requireContext(), request?.url, this@Stripe3DSDialogFragment::handleLaunchExternal)
|
||||
}
|
||||
|
||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||
binding.progress.visible = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
|
||||
fun interface StripeNextActionHandler {
|
||||
fun handle(
|
||||
action: StripeApi.Secure3DSAction,
|
||||
stripe3DSData: Stripe3DSData
|
||||
): Single<StripeIntentAccessor>
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
viewModel.processNewDonation(args.request, this::handleSecure3dsAction)
|
||||
}
|
||||
DonationProcessorAction.UPDATE_SUBSCRIPTION -> {
|
||||
viewModel.updateSubscription(args.request)
|
||||
viewModel.updateSubscription(args.request, args.isLongRunning)
|
||||
}
|
||||
DonationProcessorAction.CANCEL_SUBSCRIPTION -> {
|
||||
viewModel.cancelSubscription()
|
||||
@@ -116,7 +116,7 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction): Single<StripeIntentAccessor> {
|
||||
private fun handleSecure3dsAction(secure3dsAction: StripeApi.Secure3DSAction, stripe3DSData: Stripe3DSData): Single<StripeIntentAccessor> {
|
||||
return when (secure3dsAction) {
|
||||
is StripeApi.Secure3DSAction.NotNeeded -> {
|
||||
Log.d(TAG, "No 3DS action required.")
|
||||
@@ -124,19 +124,24 @@ class StripePaymentInProgressFragment : DialogFragment(R.layout.donation_in_prog
|
||||
}
|
||||
is StripeApi.Secure3DSAction.ConfirmRequired -> {
|
||||
Log.d(TAG, "3DS action required. Displaying dialog...")
|
||||
Single.create<StripeIntentAccessor> { emitter ->
|
||||
Single.create { emitter ->
|
||||
val listener = FragmentResultListener { _, bundle ->
|
||||
val result: StripeIntentAccessor? = bundle.getParcelableCompat(Stripe3DSDialogFragment.REQUEST_KEY, StripeIntentAccessor::class.java)
|
||||
if (result != null) {
|
||||
emitter.onSuccess(result)
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
val didLaunchExternal = bundle.getBoolean(Stripe3DSDialogFragment.LAUNCHED_EXTERNAL, false)
|
||||
if (didLaunchExternal) {
|
||||
emitter.onError(DonationError.UserLaunchedExternalApplication(args.request.donateToSignalType.toErrorSource()))
|
||||
} else {
|
||||
emitter.onError(DonationError.UserCancelledPaymentError(args.request.donateToSignalType.toErrorSource()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY, this, listener)
|
||||
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri))
|
||||
findNavController().safeNavigate(StripePaymentInProgressFragmentDirections.actionStripePaymentInProgressFragmentToStripe3dsDialogFragment(secure3dsAction.uri, secure3dsAction.returnUri, stripe3DSData))
|
||||
|
||||
emitter.setCancellable {
|
||||
parentFragmentManager.clearFragmentResultListener(Stripe3DSDialogFragment.REQUEST_KEY)
|
||||
|
||||
@@ -69,12 +69,12 @@ class StripePaymentInProgressViewModel(
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun processNewDonation(request: GatewayRequest, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
fun processNewDonation(request: GatewayRequest, nextActionHandler: StripeNextActionHandler) {
|
||||
Log.d(TAG, "Proceeding with donation...", true)
|
||||
|
||||
val errorSource = when (request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.BOOST
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.SUBSCRIPTION
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
@@ -93,14 +93,22 @@ class StripePaymentInProgressViewModel(
|
||||
PaymentSourceType.Stripe.GooglePay,
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.CreditCard -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.CreditCard,
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.SEPADebit,
|
||||
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
is StripePaymentData.IDEAL -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.IDEAL,
|
||||
stripeRepository.createIdealPaymentSource(data.idealData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
|
||||
else -> error("This should never happen.")
|
||||
}
|
||||
}
|
||||
@@ -120,6 +128,11 @@ class StripePaymentInProgressViewModel(
|
||||
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
|
||||
}
|
||||
|
||||
fun provideIDEALData(bankData: StripeApi.IDEALData) {
|
||||
requireNoPaymentInformation()
|
||||
this.stripePaymentData = StripePaymentData.IDEAL(bankData)
|
||||
}
|
||||
|
||||
private fun requireNoPaymentInformation() {
|
||||
require(stripePaymentData == null)
|
||||
}
|
||||
@@ -129,13 +142,13 @@ class StripePaymentInProgressViewModel(
|
||||
stripePaymentData = null
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: StripeNextActionHandler) {
|
||||
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
|
||||
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
|
||||
}
|
||||
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request, paymentSourceProvider.paymentSourceType.isBankTransfer)
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
@@ -144,16 +157,22 @@ class StripePaymentInProgressViewModel(
|
||||
.andThen(monthlyDonationRepository.cancelActiveSubscriptionIfNecessary())
|
||||
.andThen(createAndConfirmSetupIntent)
|
||||
.flatMap { secure3DSAction ->
|
||||
nextActionHandler(secure3DSAction)
|
||||
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult) }
|
||||
.map { (_, paymentMethod) -> paymentMethod ?: secure3DSAction.paymentMethodId!! }
|
||||
nextActionHandler.handle(
|
||||
action = secure3DSAction,
|
||||
Stripe3DSData(
|
||||
secure3DSAction.stripeIntentAccessor,
|
||||
request,
|
||||
paymentSourceProvider.paymentSourceType.code
|
||||
)
|
||||
)
|
||||
.flatMap { secure3DSResult -> stripeRepository.getStatusAndPaymentMethodId(secure3DSResult, secure3DSAction.paymentMethodId) }
|
||||
}
|
||||
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) }
|
||||
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it.paymentMethod!!, it.intentId, paymentSourceProvider.paymentSourceType) }
|
||||
.onErrorResumeNext {
|
||||
when {
|
||||
it is DonationError -> Completable.error(it)
|
||||
it is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.SUBSCRIPTION, paymentSourceProvider.paymentSourceType))
|
||||
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
|
||||
when (it) {
|
||||
is DonationError -> Completable.error(it)
|
||||
is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.MONTHLY, paymentSourceProvider.paymentSourceType))
|
||||
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.MONTHLY, it, paymentSourceProvider.paymentSourceType))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +184,7 @@ class StripePaymentInProgressViewModel(
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
@@ -179,7 +198,7 @@ class StripePaymentInProgressViewModel(
|
||||
private fun proceedOneTime(
|
||||
request: GatewayRequest,
|
||||
paymentSourceProvider: PaymentSourceProvider,
|
||||
nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>
|
||||
nextActionHandler: StripeNextActionHandler
|
||||
) {
|
||||
Log.w(TAG, "Beginning one-time payment pipeline...", true)
|
||||
|
||||
@@ -195,17 +214,24 @@ class StripePaymentInProgressViewModel(
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, request.recipientId)
|
||||
.flatMap { nextActionHandler(it) }
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it) }
|
||||
.flatMap { action ->
|
||||
nextActionHandler
|
||||
.handle(
|
||||
action,
|
||||
Stripe3DSData(
|
||||
action.stripeIntentAccessor,
|
||||
request,
|
||||
paymentSourceProvider.paymentSourceType.code
|
||||
)
|
||||
)
|
||||
.flatMap { stripeRepository.getStatusAndPaymentMethodId(it, action.paymentMethodId) }
|
||||
}
|
||||
.flatMapCompletable {
|
||||
oneTimeDonationRepository.waitForOneTimeRedemption(
|
||||
price = amount,
|
||||
gatewayRequest = request,
|
||||
paymentIntentId = paymentIntent.intentId,
|
||||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.STRIPE,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
paymentSourceType = paymentSource.type
|
||||
)
|
||||
}
|
||||
}.subscribeBy(
|
||||
@@ -246,11 +272,10 @@ class StripePaymentInProgressViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
fun updateSubscription(request: GatewayRequest) {
|
||||
fun updateSubscription(request: GatewayRequest, isLongRunning: Boolean) {
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request, isLongRunning))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -260,8 +285,8 @@ class StripePaymentInProgressViewModel(
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.Stripe.GooglePay)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.MONTHLY, PaymentSourceType.Stripe.GooglePay)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.MONTHLY)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
@@ -279,6 +304,7 @@ class StripePaymentInProgressViewModel(
|
||||
class GooglePay(val paymentData: PaymentData) : StripePaymentData
|
||||
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
|
||||
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
|
||||
class IDEAL(val idealData: StripeApi.IDEALData) : StripePaymentData
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer
|
||||
|
||||
object BankTransferRequestKeys {
|
||||
const val REQUEST_KEY = "bank.transfer.result"
|
||||
const val PENDING_KEY = "bank.transfer.pending"
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
@@ -29,7 +31,6 @@ import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -38,6 +39,9 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
@@ -46,14 +50,21 @@ import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
@@ -61,7 +72,7 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
/**
|
||||
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
|
||||
*/
|
||||
class BankTransferDetailsFragment : ComposeFragment() {
|
||||
class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
|
||||
|
||||
private val args: BankTransferDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferDetailsViewModel by viewModels()
|
||||
@@ -73,6 +84,26 @@ class BankTransferDetailsFragment : ComposeFragment() {
|
||||
}
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state: BankTransferDetailsState by viewModel.state
|
||||
@@ -97,7 +128,8 @@ class BankTransferDetailsFragment : ComposeFragment() {
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onIBANChanged = viewModel::onIBANChanged,
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
|
||||
setDisplayFindAccountInfoSheet = viewModel::setDisplayFindAccountInfoSheet,
|
||||
onLearnMoreClick = this::onLearnMoreClick,
|
||||
onDonateClick = this::onDonateClick,
|
||||
onIBANFocusChanged = viewModel::onIBANFocusChanged,
|
||||
donateLabel = donateLabel
|
||||
@@ -108,8 +140,10 @@ class BankTransferDetailsFragment : ComposeFragment() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun onFindAccountNumbersClicked() {
|
||||
// TODO [sepa] -- FindAccountNumbersBottomSheet
|
||||
private fun onLearnMoreClick() {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()
|
||||
)
|
||||
}
|
||||
|
||||
private fun onDonateClick() {
|
||||
@@ -121,6 +155,15 @@ class BankTransferDetailsFragment : ComposeFragment() {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUserCancelledPaymentFlow() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().popBackStack()
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@@ -129,13 +172,15 @@ private fun BankTransferDetailsContentPreview() {
|
||||
SignalTheme {
|
||||
BankTransferDetailsContent(
|
||||
state = BankTransferDetailsState(
|
||||
name = "Miles Morales"
|
||||
name = "Miles Morales",
|
||||
displayFindAccountInfoSheet = true
|
||||
),
|
||||
onNavigationClick = {},
|
||||
onNameChanged = {},
|
||||
onIBANChanged = {},
|
||||
onEmailChanged = {},
|
||||
onFindAccountNumbersClicked = {},
|
||||
setDisplayFindAccountInfoSheet = {},
|
||||
onLearnMoreClick = {},
|
||||
onDonateClick = {},
|
||||
onIBANFocusChanged = {},
|
||||
donateLabel = "Donate $5/month"
|
||||
@@ -150,7 +195,8 @@ private fun BankTransferDetailsContent(
|
||||
onNameChanged: (String) -> Unit,
|
||||
onIBANChanged: (String) -> Unit,
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onFindAccountNumbersClicked: () -> Unit,
|
||||
setDisplayFindAccountInfoSheet: (Boolean) -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onDonateClick: () -> Unit,
|
||||
onIBANFocusChanged: (Boolean) -> Unit,
|
||||
donateLabel: String
|
||||
@@ -178,14 +224,13 @@ private fun BankTransferDetailsContent(
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
onLearnMoreClick()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
@@ -211,11 +256,11 @@ private fun BankTransferDetailsContent(
|
||||
if (state.ibanValidity.isError) {
|
||||
Text(
|
||||
text = when (state.ibanValidity) {
|
||||
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
|
||||
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
|
||||
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_is_too_short)
|
||||
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_is_too_long)
|
||||
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
|
||||
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban)
|
||||
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban)
|
||||
else -> error("Unexpected error.")
|
||||
}
|
||||
)
|
||||
@@ -276,9 +321,9 @@ private fun BankTransferDetailsContent(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onFindAccountNumbersClicked
|
||||
onClick = { setDisplayFindAccountInfoSheet(true) }
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_info))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +339,10 @@ private fun BankTransferDetailsContent(
|
||||
Text(text = donateLabel)
|
||||
}
|
||||
|
||||
if (state.displayFindAccountInfoSheet) {
|
||||
FindAccountInfoSheet { setDisplayFindAccountInfoSheet(false) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
@@ -11,15 +11,16 @@ data class BankTransferDetailsState(
|
||||
val name: String = "",
|
||||
val iban: String = "",
|
||||
val email: String = "",
|
||||
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
|
||||
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID,
|
||||
val displayFindAccountInfoSheet: Boolean = false
|
||||
) {
|
||||
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
|
||||
val canProceed = name.isNotBlank() && email.isNotBlank() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
|
||||
|
||||
fun asSEPADebitData(): StripeApi.SEPADebitData {
|
||||
return StripeApi.SEPADebitData(
|
||||
iban = iban,
|
||||
name = name,
|
||||
email = email
|
||||
iban = iban.trim(),
|
||||
name = name.trim(),
|
||||
email = email.trim()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@ class BankTransferDetailsViewModel : ViewModel() {
|
||||
private val internalState = mutableStateOf(BankTransferDetailsState())
|
||||
val state: State<BankTransferDetailsState> = internalState
|
||||
|
||||
fun setDisplayFindAccountInfoSheet(displayFindAccountInfoSheet: Boolean) {
|
||||
internalState.value = internalState.value.copy(
|
||||
displayFindAccountInfoSheet = displayFindAccountInfoSheet
|
||||
)
|
||||
}
|
||||
|
||||
fun onNameChanged(name: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
name = name
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Displays a modal bottom sheet that explains where to find the information necessary to perform
|
||||
* a bank transfer.
|
||||
*/
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun FindAccountInfoSheet(
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
dragHandle = { BottomSheets.Handle() }
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.find_account_info),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.align(CenterHorizontally)
|
||||
.padding(vertical = 32.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.FindAccountInfoSheet__find_your_account_information),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 60.dp)
|
||||
.align(CenterHorizontally)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.FindAccountInfoSheet__look_for_your_iban_at),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp, bottom = 48.dp, start = 60.dp, end = 60.dp)
|
||||
.align(CenterHorizontally)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -95,17 +95,19 @@ object IBANValidator {
|
||||
}
|
||||
|
||||
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
|
||||
if (iban.isEmpty()) {
|
||||
val trimmedIban = iban.trim()
|
||||
|
||||
if (trimmedIban.isEmpty()) {
|
||||
return Validity.POTENTIALLY_VALID
|
||||
}
|
||||
|
||||
val lengthValidity = validateLength(iban, isIBANFieldFocused)
|
||||
val lengthValidity = validateLength(trimmedIban, isIBANFieldFocused)
|
||||
if (lengthValidity != Validity.COMPLETELY_VALID) {
|
||||
return lengthValidity
|
||||
}
|
||||
|
||||
val countryAndCheck = iban.take(4)
|
||||
val rearranged = iban.drop(4) + countryAndCheck
|
||||
val countryAndCheck = trimmedIban.take(4)
|
||||
val rearranged = trimmedIban.drop(4) + countryAndCheck
|
||||
val expanded = rearranged.map {
|
||||
if (it.isLetter()) {
|
||||
(it - 'A') + 10
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.util.EnumMap
|
||||
|
||||
/**
|
||||
* Set of banks that are supported for iDEAL transfers, as listed here:
|
||||
* https://stripe.com/docs/api/payment_methods/object#payment_method_object-ideal-bank
|
||||
*/
|
||||
enum class IdealBank(
|
||||
val code: String
|
||||
) {
|
||||
ABN_AMRO("abn_amro"),
|
||||
ASN_BANK("asn_bank"),
|
||||
BUNQ("bunq"),
|
||||
ING("ing"),
|
||||
KNAB("knab"),
|
||||
N26("n26"),
|
||||
RABOBANK("rabobank"),
|
||||
REGIOBANK("regiobank"),
|
||||
REVOLUT("revolut"),
|
||||
SNS_BANK("sns_bank"),
|
||||
TRIODOS_BANK("triodos_bank"),
|
||||
VAN_LANCHOT("van_lanchot"),
|
||||
YOURSAFE("yoursafe");
|
||||
|
||||
fun getUIValues(): UIValues = bankToUIValues[this]!!
|
||||
|
||||
companion object {
|
||||
|
||||
private val bankToUIValues: Map<IdealBank, UIValues> by lazy {
|
||||
EnumMap<IdealBank, UIValues>(IdealBank::class.java).apply {
|
||||
putAll(
|
||||
arrayOf(
|
||||
ABN_AMRO to UIValues(
|
||||
name = R.string.IdealBank__abn_amro,
|
||||
icon = R.drawable.ideal_abn_amro
|
||||
),
|
||||
ASN_BANK to UIValues(
|
||||
name = R.string.IdealBank__asn_bank,
|
||||
icon = R.drawable.ideal_asn
|
||||
),
|
||||
BUNQ to UIValues(
|
||||
name = R.string.IdealBank__bunq,
|
||||
icon = R.drawable.ideal_bunq
|
||||
),
|
||||
ING to UIValues(
|
||||
name = R.string.IdealBank__ing,
|
||||
icon = R.drawable.ideal_ing
|
||||
),
|
||||
KNAB to UIValues(
|
||||
name = R.string.IdealBank__knab,
|
||||
icon = R.drawable.ideal_knab
|
||||
),
|
||||
N26 to UIValues(
|
||||
name = R.string.IdealBank__n26,
|
||||
icon = R.drawable.ideal_n26
|
||||
),
|
||||
RABOBANK to UIValues(
|
||||
name = R.string.IdealBank__rabobank,
|
||||
icon = R.drawable.ideal_rabobank
|
||||
),
|
||||
REGIOBANK to UIValues(
|
||||
name = R.string.IdealBank__regiobank,
|
||||
icon = R.drawable.ideal_regiobank
|
||||
),
|
||||
REVOLUT to UIValues(
|
||||
name = R.string.IdealBank__revolut,
|
||||
icon = R.drawable.ideal_revolut
|
||||
),
|
||||
SNS_BANK to UIValues(
|
||||
name = R.string.IdealBank__sns_bank,
|
||||
icon = R.drawable.ideal_sns
|
||||
),
|
||||
TRIODOS_BANK to UIValues(
|
||||
name = R.string.IdealBank__triodos_bank,
|
||||
icon = R.drawable.ideal_triodos_bank
|
||||
),
|
||||
VAN_LANCHOT to UIValues(
|
||||
name = R.string.IdealBank__van_lanchot,
|
||||
icon = R.drawable.ideal_van_lanschot
|
||||
),
|
||||
YOURSAFE to UIValues(
|
||||
name = R.string.IdealBank__yoursafe,
|
||||
icon = R.drawable.ideal_yoursafe
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fromCode(code: String): IdealBank {
|
||||
return values().first { it.code == code }
|
||||
}
|
||||
}
|
||||
|
||||
data class UIValues(
|
||||
@StringRes val name: Int,
|
||||
@DrawableRes val icon: Int
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment.Companion.CenterVertically
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
|
||||
/**
|
||||
* Dialog fragment for selecting the bank for the iDEAL donation.
|
||||
*/
|
||||
class IdealTransferDetailsBankSelectionDialogFragment : ComposeDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val IDEAL_SELECTED_BANK = "ideal.selected.bank"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
BankSelectionContent(
|
||||
onNavigationClick = { findNavController().popBackStack() },
|
||||
onBankSelected = {
|
||||
dismissAllowingStateLoss()
|
||||
|
||||
setFragmentResult(
|
||||
IDEAL_SELECTED_BANK,
|
||||
bundleOf(
|
||||
IDEAL_SELECTED_BANK to it.code
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BankSelectionContentPreview() {
|
||||
BankSelectionContent(
|
||||
onNavigationClick = {},
|
||||
onBankSelected = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BankSelectionContent(
|
||||
onNavigationClick: () -> Unit,
|
||||
onBankSelected: (IdealBank) -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.IdealTransferDetailsBankSelectionDialogFragment__choose_your_bank),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_x_24)
|
||||
) { paddingValues ->
|
||||
LazyColumn(modifier = Modifier.padding(paddingValues)) {
|
||||
items(IdealBank.values()) {
|
||||
val uiValues = it.getUIValues()
|
||||
|
||||
Row(
|
||||
verticalAlignment = CenterVertically,
|
||||
modifier = Modifier
|
||||
.clickable { onBankSelected(it) }
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter), vertical = 8.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = uiValues.icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(uiValues.name),
|
||||
modifier = Modifier.padding(start = 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.TemporaryScreenshotSecurity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationCheckoutDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorActionResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.BankTransferRequestKeys
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Fragment for inputting necessary bank transfer information for iDEAL donation
|
||||
*/
|
||||
class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.ErrorHandlerCallback {
|
||||
|
||||
private val args: IdealTransferDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: IdealTransferDetailsViewModel by viewModels()
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
TemporaryScreenshotSecurity.bindToViewLifecycleOwner(this)
|
||||
|
||||
val errorSource: DonationErrorSource = when (args.request.donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> DonationErrorSource.ONE_TIME
|
||||
DonateToSignalType.MONTHLY -> DonationErrorSource.MONTHLY
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, this, args.request.uiSessionKey, errorSource)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
if (result.status == DonationProcessorActionResult.Status.SUCCESS) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
setFragmentResult(BankTransferRequestKeys.REQUEST_KEY, bundle)
|
||||
}
|
||||
}
|
||||
|
||||
setFragmentResultListener(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK) { _, bundle ->
|
||||
val bankCode = bundle.getString(IdealTransferDetailsBankSelectionDialogFragment.IDEAL_SELECTED_BANK)!!
|
||||
viewModel.onBankSelected(IdealBank.fromCode(bankCode))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
|
||||
val donateLabel = remember(args.request) {
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IdealTransferDetailsContent(
|
||||
state = state,
|
||||
donateLabel = donateLabel,
|
||||
onNavigationClick = { findNavController().popBackStack() },
|
||||
onLearnMoreClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToYourInformationIsPrivateBottomSheet()) },
|
||||
onSelectBankClick = { findNavController().navigate(IdealTransferDetailsFragmentDirections.actionIdealTransferDetailsFragmentToIdealTransferBankSelectionDialogFragment()) },
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onDonateClick = this::onDonateClick
|
||||
)
|
||||
}
|
||||
|
||||
private fun onDonateClick() {
|
||||
stripePaymentViewModel.provideIDEALData(viewModel.state.value.asIDEALData())
|
||||
findNavController().safeNavigate(
|
||||
IdealTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUserCancelledPaymentFlow() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().popBackStack()
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun IdealTransferDetailsContentPreview() {
|
||||
IdealTransferDetailsContent(
|
||||
state = IdealTransferDetailsState(),
|
||||
donateLabel = "Donate $5/month",
|
||||
onNavigationClick = {},
|
||||
onLearnMoreClick = {},
|
||||
onSelectBankClick = {},
|
||||
onNameChanged = {},
|
||||
onEmailChanged = {},
|
||||
onDonateClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdealTransferDetailsContent(
|
||||
state: IdealTransferDetailsState,
|
||||
donateLabel: String,
|
||||
onNavigationClick: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onSelectBankClick: () -> Unit,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onDonateClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.GatewaySelectorBottomSheet__ideal),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier.padding(it)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.IdealTransferDetailsFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.IdealTransferDetailsFragment__enter_your_bank, learnMore)
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_faq_url)),
|
||||
onUrlClick = {
|
||||
onLearnMoreClick()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
IdealBankSelector(
|
||||
idealBank = state.idealBank,
|
||||
onSelectBankClick = onSelectBankClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__name_on_bank_account))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.IdealTransferDetailsFragment__email))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
if (state.canProceed()) {
|
||||
onDonateClick()
|
||||
}
|
||||
}
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.canProceed(),
|
||||
onClick = onDonateClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = donateLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun IdealBankSelectorPreview() {
|
||||
IdealBankSelector(
|
||||
idealBank = null,
|
||||
onSelectBankClick = {}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IdealBankSelector(
|
||||
idealBank: IdealBank?,
|
||||
onSelectBankClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val uiValues: IdealBank.UIValues? = remember(idealBank) { idealBank?.getUIValues() }
|
||||
val imagePadding: Dp = if (idealBank == null) 4.dp else 0.dp
|
||||
|
||||
TextField(
|
||||
value = stringResource(id = uiValues?.name ?: R.string.IdealTransferDetailsFragment__choose_your_bank),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
readOnly = true,
|
||||
leadingIcon = {
|
||||
Image(
|
||||
painter = painterResource(id = uiValues?.icon ?: R.drawable.bank_transfer),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, end = 12.dp)
|
||||
.size(32.dp)
|
||||
.padding(imagePadding)
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_dropdown_triangle_compat_bold_16),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
colors = TextFieldDefaults.colors(
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledTrailingIconColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = modifier
|
||||
.clickable(
|
||||
onClick = onSelectBankClick,
|
||||
role = Role.Button
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
|
||||
|
||||
import org.signal.donations.StripeApi
|
||||
|
||||
data class IdealTransferDetailsState(
|
||||
val idealBank: IdealBank? = null,
|
||||
val name: String = "",
|
||||
val email: String = ""
|
||||
) {
|
||||
fun asIDEALData(): StripeApi.IDEALData {
|
||||
return StripeApi.IDEALData(
|
||||
bank = idealBank!!.code,
|
||||
name = name.trim(),
|
||||
email = email.trim()
|
||||
)
|
||||
}
|
||||
|
||||
fun canProceed(): Boolean {
|
||||
return idealBank != null && name.isNotBlank() && email.isNotBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.ideal
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class IdealTransferDetailsViewModel : ViewModel() {
|
||||
|
||||
private val internalState = mutableStateOf(IdealTransferDetailsState())
|
||||
var state: State<IdealTransferDetailsState> = internalState
|
||||
|
||||
fun onNameChanged(name: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
fun onEmailChanged(email: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
email = email
|
||||
)
|
||||
}
|
||||
|
||||
fun onBankSelected(idealBank: IdealBank) {
|
||||
internalState.value = internalState.value.copy(
|
||||
idealBank = idealBank
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,44 +5,63 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Displays Bank Transfer legal mandate users must agree to to move forward.
|
||||
@@ -50,16 +69,39 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
class BankTransferMandateFragment : ComposeFragment() {
|
||||
|
||||
private val args: BankTransferMandateFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferMandateViewModel by viewModels()
|
||||
private val viewModel: BankTransferMandateViewModel by viewModel {
|
||||
BankTransferMandateViewModel(PaymentSourceType.Stripe.SEPADebit)
|
||||
}
|
||||
|
||||
private lateinit var statusBarColorAnimator: StatusBarColorAnimator
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
statusBarColorAnimator = StatusBarColorAnimator(requireActivity())
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
statusBarColorAnimator.setColorImmediate()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val mandate by viewModel.mandate
|
||||
val failedToLoadMandate by viewModel.failedToLoadMandate
|
||||
|
||||
BankTransferScreen(
|
||||
bankMandate = mandate,
|
||||
failedToLoadMandate = failedToLoadMandate,
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onContinueClick = this::onContinueClick
|
||||
onContinueClick = this::onContinueClick,
|
||||
onLearnMoreClick = this::onLearnMoreClick,
|
||||
onCanScrollUp = statusBarColorAnimator::setCanScrollUp
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLearnMoreClick() {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToYourInformationIsPrivateBottomSheet()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,9 +110,15 @@ class BankTransferMandateFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
private fun onContinueClick() {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
|
||||
)
|
||||
if (args.response.gateway == GatewayResponse.Gateway.SEPA_DEBIT) {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.response.request)
|
||||
)
|
||||
} else {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToIdealTransferDetailsFragment(args.response.request)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,39 +128,70 @@ fun BankTransferScreenPreview() {
|
||||
SignalTheme {
|
||||
BankTransferScreen(
|
||||
bankMandate = "Test ".repeat(500),
|
||||
failedToLoadMandate = false,
|
||||
onNavigationClick = {},
|
||||
onContinueClick = {}
|
||||
onContinueClick = {},
|
||||
onLearnMoreClick = {},
|
||||
onCanScrollUp = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun BankTransferScreen(
|
||||
bankMandate: String,
|
||||
failedToLoadMandate: Boolean,
|
||||
onNavigationClick: () -> Unit,
|
||||
onContinueClick: () -> Unit
|
||||
onContinueClick: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
onCanScrollUp: (Boolean) -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedVisibility(
|
||||
visible = listState.canScrollBackward,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavigationClick,
|
||||
Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24)),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = if (listState.canScrollBackward) TopAppBarDefaults.topAppBarColors(containerColor = SignalTheme.colors.colorSurface2) else TopAppBarDefaults.topAppBarColors()
|
||||
)
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
onCanScrollUp(listState.canScrollBackward)
|
||||
|
||||
Column(horizontalAlignment = CenterHorizontally, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.weight(1f, true)
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
|
||||
painter = painterResource(id = R.drawable.bank_transfer),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
@@ -135,17 +214,18 @@ fun BankTransferScreen(
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
onLearnMoreClick()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
|
||||
modifier = Modifier
|
||||
.padding(bottom = 12.dp)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -155,20 +235,36 @@ fun BankTransferScreen(
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = bankMandate,
|
||||
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 46.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
|
||||
if (!failedToLoadMandate) {
|
||||
Surface(
|
||||
shadowElevation = if (listState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = {
|
||||
if (!listState.canScrollForward) {
|
||||
onContinueClick()
|
||||
} else {
|
||||
scope.launch {
|
||||
listState.animateScrollBy(value = 1000f)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.padding(top = 16.dp, bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user