mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-13 13:33:20 +01:00
Compare commits
164 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1222c30738 | ||
|
|
0c8e62add9 | ||
|
|
eb1d06b4a6 | ||
|
|
d58c3292d7 | ||
|
|
4320d26a3d | ||
|
|
3ca4e33d94 | ||
|
|
19e726a630 | ||
|
|
96dddef271 | ||
|
|
34a228f85e | ||
|
|
213d996168 | ||
|
|
5a159ce01f | ||
|
|
fed9c64113 | ||
|
|
2d835581a5 | ||
|
|
c8f1ebdf4c | ||
|
|
98e3530acd | ||
|
|
1a5b216dd5 | ||
|
|
ae98d5e3bd | ||
|
|
750825b3c3 | ||
|
|
8c255256c9 | ||
|
|
19626361ec | ||
|
|
df4bd1fa4a | ||
|
|
62bf5abd8d | ||
|
|
cd9ec9f346 | ||
|
|
cf7d5b3481 | ||
|
|
12f9ac3aa4 | ||
|
|
4519cdb49c | ||
|
|
d20b6f355c | ||
|
|
70e64003f9 | ||
|
|
0a4644e743 | ||
|
|
c428d23d8b | ||
|
|
d6b189badc | ||
|
|
6e899391c0 | ||
|
|
e0acbcc32d | ||
|
|
95fb9ea117 | ||
|
|
e80b7cf0a2 | ||
|
|
5e70c06075 | ||
|
|
1413b74f76 | ||
|
|
bf0548e802 | ||
|
|
b7e1863526 | ||
|
|
f189188563 | ||
|
|
2f52664820 | ||
|
|
5f6fa73be9 | ||
|
|
b7ec913cb9 | ||
|
|
ebef4b079c | ||
|
|
a81e5c4e6b | ||
|
|
b0733dcd51 | ||
|
|
e9bd35619d | ||
|
|
6528b34152 | ||
|
|
b60c02e0c7 | ||
|
|
a0792d166b | ||
|
|
fcf36c4bc0 | ||
|
|
e5b617cd16 | ||
|
|
0acefb4521 | ||
|
|
111c8367a9 | ||
|
|
ead8f209b6 | ||
|
|
96333b616b | ||
|
|
5698e0deda | ||
|
|
df2ddebf6c | ||
|
|
71ab7528e7 | ||
|
|
b4e459d831 | ||
|
|
a57d3fdf3f | ||
|
|
2c207873be | ||
|
|
fc8385113f | ||
|
|
95d7d26f11 | ||
|
|
43a13964bd | ||
|
|
d2053d2db7 | ||
|
|
8ba2bcaa53 | ||
|
|
f7abdbe97f | ||
|
|
91af3e60ba | ||
|
|
8fe196cd7a | ||
|
|
66d7241c03 | ||
|
|
89d7c0b0d0 | ||
|
|
d2ec62d681 | ||
|
|
b6d38fe8f1 | ||
|
|
1edc256148 | ||
|
|
24ac385898 | ||
|
|
f062e58f7b | ||
|
|
96aec401b9 | ||
|
|
7ff0b7aa3c | ||
|
|
e5ab5241d5 | ||
|
|
0f4f87067e | ||
|
|
3f32f816b0 | ||
|
|
73de2dfda7 | ||
|
|
d6fd6cb5a3 | ||
|
|
39fbbe896f | ||
|
|
29c70acf4e | ||
|
|
5cd2568776 | ||
|
|
60a6535a12 | ||
|
|
f48b389449 | ||
|
|
316dd210a0 | ||
|
|
a60712c09d | ||
|
|
482cd564ff | ||
|
|
ac1171d43b | ||
|
|
ed8953c430 | ||
|
|
9a8aecaf3f | ||
|
|
423719e7bc | ||
|
|
7f2b6a874e | ||
|
|
cfe5ea3f9b | ||
|
|
07aa058a46 | ||
|
|
6cadf93c43 | ||
|
|
60eb1332d2 | ||
|
|
a9ee7e93fd | ||
|
|
2782216e52 | ||
|
|
d22537c5f2 | ||
|
|
57aa6c19e1 | ||
|
|
761553d392 | ||
|
|
29350ab7b0 | ||
|
|
528ccc1e9d | ||
|
|
20d26ad7ca | ||
|
|
5d23c5c902 | ||
|
|
145794bf04 | ||
|
|
d00f2aa8d0 | ||
|
|
3a20375567 | ||
|
|
7be93a8a44 | ||
|
|
b5e4c4e92a | ||
|
|
20285796bd | ||
|
|
7826ff94e3 | ||
|
|
f1dccbb64d | ||
|
|
528e301ce4 | ||
|
|
af016a9c79 | ||
|
|
cbd5738543 | ||
|
|
2dd0899a3d | ||
|
|
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 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,7 @@ captures/
|
||||
project.properties
|
||||
keystore.debug.properties
|
||||
keystore.staging.properties
|
||||
nightly-url.txt
|
||||
.project
|
||||
.settings
|
||||
bin/
|
||||
@@ -28,4 +29,4 @@ jni/libspeex/.deps/
|
||||
pkcs11.password
|
||||
dev.keystore
|
||||
maps.key
|
||||
local/
|
||||
local/
|
||||
|
||||
@@ -33,8 +33,8 @@ ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1351
|
||||
def canonicalVersionName = "6.38.0"
|
||||
def canonicalVersionCode = 1365
|
||||
def canonicalVersionName = "6.41.3"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -320,22 +320,26 @@ android {
|
||||
dimension 'distribution'
|
||||
isDefault true
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
|
||||
buildConfigField "String", "APK_UPDATE_URL", "null"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "null"
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
|
||||
}
|
||||
|
||||
website {
|
||||
dimension 'distribution'
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_URL", "\"https://updates.signal.org/android\""
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"https://updates.signal.org/android/latest.json\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
|
||||
}
|
||||
|
||||
nightly {
|
||||
def apkUpdateManifestUrl = "<unset>"
|
||||
if (file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
apkUpdateManifestUrl = file("${project.rootDir}/nightly-url.txt").text.trim()
|
||||
}
|
||||
dimension 'distribution'
|
||||
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "false"
|
||||
buildConfigField "String", "APK_UPDATE_URL", "null"
|
||||
buildConfigField "boolean", "MANAGES_APP_UPDATES", "true"
|
||||
buildConfigField "String", "APK_UPDATE_MANIFEST_URL", "\"${apkUpdateManifestUrl}\""
|
||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||
}
|
||||
|
||||
@@ -400,6 +404,9 @@ android {
|
||||
tag = tag.substring(1)
|
||||
}
|
||||
output.versionNameOverride = tag
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${output.versionNameOverride}.apk")
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
}
|
||||
} else {
|
||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||
@@ -659,6 +666,24 @@ tasks.withType(Test) {
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.configureEach { task ->
|
||||
if (task.name.toLowerCase().contains("nightly") && task.name != 'checkNightlyParams') {
|
||||
task.dependsOn checkNightlyParams
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register('checkNightlyParams') {
|
||||
doFirst {
|
||||
if (project.gradle.startParameter.taskNames.any { it.toLowerCase().contains("nightly") }) {
|
||||
|
||||
if (!file("${project.rootDir}/nightly-url.txt").exists()) {
|
||||
throw new GradleException("Cannot fine 'nightly-url.txt' for nightly build! It must exist in the root of this project and contain the location of the nightly manifest.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def loadKeystoreProperties(filename) {
|
||||
def keystorePropertiesFile = file("${project.rootDir}/${filename}")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.usernames.Username
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Delete
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.failure
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RefreshOwnProfileJob__checkUsernameIsInSyncTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
SignalStore.account().usernameOutOfSync = false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoLocalUsername_whenICheckUsernameIsInSync_thenIExpectNoFailures() {
|
||||
// GIVEN
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Delete("/v1/accounts/username_hash") { MockResponse().success() }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalUsernameDoesNotMatchServerUsername_whenICheckUsernameIsInSync_thenIExpectRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
val serverUsername = "hello.3232"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(serverUsername))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalAndNoServer_whenICheckUsernameIsInSync_thenIExpectRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(WhoAmIResponse())
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertTrue(didConfirm)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenLocalAndServerMatch_whenICheckUsernameIsInSync_thenIExpectNoRetry() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash(username))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().success(ReserveUsernameResponse(Base64.encodeUrlSafeWithoutPadding(Username.hash(username))))
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertFalse(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertFalse(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMismatchAndReservationFails_whenICheckUsernameIsInSync_thenIExpectNoConfirm() {
|
||||
// GIVEN
|
||||
var didReserve = false
|
||||
var didConfirm = false
|
||||
val username = "hello.32"
|
||||
SignalDatabase.recipients.setUsername(harness.self.id, username)
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/accounts/whoami") { r ->
|
||||
MockResponse().success(
|
||||
WhoAmIResponse().apply {
|
||||
usernameHash = Base64.encodeUrlSafeWithoutPadding(Username.hash("${username}23"))
|
||||
}
|
||||
)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/reserve") { r ->
|
||||
didReserve = true
|
||||
MockResponse().failure(418)
|
||||
},
|
||||
Put("/v1/accounts/username_hash/confirm") { r ->
|
||||
didConfirm = true
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
// WHEN
|
||||
RefreshOwnProfileJob.checkUsernameIsInSync()
|
||||
|
||||
// THEN
|
||||
assertTrue(didReserve)
|
||||
assertFalse(didConfirm)
|
||||
assertTrue(SignalStore.account().usernameOutOfSync)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.IncomingTextOnly
|
||||
import org.thoughtcrime.securesms.conversation.v2.data.OutgoingTextOnly
|
||||
import org.thoughtcrime.securesms.database.MessageTypes
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
@@ -78,7 +78,7 @@ class ConversationElementGenerator {
|
||||
|
||||
val isIncoming = random.nextBoolean()
|
||||
|
||||
val record = MediaMmsMessageRecord(
|
||||
val record = MmsMessageRecord(
|
||||
messageId,
|
||||
if (isIncoming) Recipient.UNKNOWN else Recipient.self(),
|
||||
0,
|
||||
@@ -86,7 +86,7 @@ class ConversationElementGenerator {
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
1,
|
||||
true,
|
||||
1,
|
||||
testMessage,
|
||||
SlideDeck(),
|
||||
@@ -97,7 +97,7 @@ class ConversationElementGenerator {
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
1,
|
||||
true,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
@@ -106,7 +106,7 @@ class ConversationElementGenerator {
|
||||
false,
|
||||
false,
|
||||
now,
|
||||
1,
|
||||
true,
|
||||
now,
|
||||
null,
|
||||
StoryType.NONE,
|
||||
|
||||
@@ -963,7 +963,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".profiles.edit.EditProfileActivity"
|
||||
<activity android:name=".profiles.edit.CreateProfileActivity"
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
@@ -973,7 +973,7 @@
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".profiles.manage.ManageProfileActivity"
|
||||
<activity android:name=".profiles.manage.EditProfileActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize"
|
||||
android:exported="false"/>
|
||||
@@ -1189,6 +1189,10 @@
|
||||
android:name=".service.GenericForegroundService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".service.AttachmentProgressService"
|
||||
android:exported="false"/>
|
||||
|
||||
<service
|
||||
android:name=".gcm.FcmFetchBackgroundService"
|
||||
android:exported="false"/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
800
app/src/main/java/org/conscrypt/ConscryptSignal.java
Normal file
800
app/src/main/java/org/conscrypt/ConscryptSignal.java
Normal file
@@ -0,0 +1,800 @@
|
||||
/*
|
||||
* Copyright (C) 2017 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.conscrypt;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.security.KeyManagementException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Provider;
|
||||
import java.security.cert.X509Certificate;
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLContextSpi;
|
||||
import javax.net.ssl.SSLEngine;
|
||||
import javax.net.ssl.SSLEngineResult;
|
||||
import javax.net.ssl.SSLException;
|
||||
import javax.net.ssl.SSLServerSocketFactory;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSessionContext;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
/**
|
||||
* Core API for creating and configuring all Conscrypt types.
|
||||
* This is identical to the original Conscrypt.java, except with the slow
|
||||
* version initialization code removed.
|
||||
*/
|
||||
@SuppressWarnings("unused")
|
||||
public final class ConscryptSignal {
|
||||
private ConscryptSignal() {}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the Conscrypt native library has been successfully loaded.
|
||||
*/
|
||||
public static boolean isAvailable() {
|
||||
try {
|
||||
checkAvailability();
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// BEGIN MODIFICATION
|
||||
/*public static class Version {
|
||||
private final int major;
|
||||
private final int minor;
|
||||
private final int patch;
|
||||
|
||||
private Version(int major, int minor, int patch) {
|
||||
this.major = major;
|
||||
this.minor = minor;
|
||||
this.patch = patch;
|
||||
}
|
||||
|
||||
public int major() { return major; }
|
||||
public int minor() { return minor; }
|
||||
public int patch() { return patch; }
|
||||
}
|
||||
|
||||
private static final Version VERSION;
|
||||
|
||||
static {
|
||||
int major = -1;
|
||||
int minor = -1;
|
||||
int patch = -1;
|
||||
InputStream stream = null;
|
||||
try {
|
||||
stream = Conscrypt.class.getResourceAsStream("conscrypt.properties");
|
||||
if (stream != null) {
|
||||
Properties props = new Properties();
|
||||
props.load(stream);
|
||||
major = Integer.parseInt(props.getProperty("org.conscrypt.version.major", "-1"));
|
||||
minor = Integer.parseInt(props.getProperty("org.conscrypt.version.minor", "-1"));
|
||||
patch = Integer.parseInt(props.getProperty("org.conscrypt.version.patch", "-1"));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// TODO(prb): This should probably be fatal or have some fallback behaviour
|
||||
} finally {
|
||||
IoUtils.closeQuietly(stream);
|
||||
}
|
||||
if ((major >= 0) && (minor >= 0) && (patch >= 0)) {
|
||||
VERSION = new Version(major, minor, patch);
|
||||
} else {
|
||||
VERSION = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the version of this distribution of Conscrypt. If version information is
|
||||
* unavailable, returns {@code null}.
|
||||
*/
|
||||
/*public static Version version() {
|
||||
return VERSION;
|
||||
}*/
|
||||
|
||||
// END MODIFICATION
|
||||
|
||||
/**
|
||||
* Checks that the Conscrypt support is available for the system.
|
||||
*
|
||||
* @throws UnsatisfiedLinkError if unavailable
|
||||
*/
|
||||
public static void checkAvailability() {
|
||||
NativeCrypto.checkAvailability();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link Provider} was created by this distribution of Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(Provider provider) {
|
||||
return provider instanceof OpenSSLProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new {@link Provider} with the default name.
|
||||
*/
|
||||
public static Provider newProvider() {
|
||||
checkAvailability();
|
||||
return new OpenSSLProvider();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new {@link Provider} with the given name.
|
||||
*
|
||||
* @deprecated Use {@link #newProviderBuilder()} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
public static Provider newProvider(String providerName) {
|
||||
checkAvailability();
|
||||
return newProviderBuilder().setName(providerName).build();
|
||||
}
|
||||
|
||||
public static class ProviderBuilder {
|
||||
private String name = Platform.getDefaultProviderName();
|
||||
private boolean provideTrustManager = Platform.provideTrustManagerByDefault();
|
||||
private String defaultTlsProtocol = NativeCrypto.SUPPORTED_PROTOCOL_TLSV1_3;
|
||||
|
||||
private ProviderBuilder() {}
|
||||
|
||||
/**
|
||||
* Sets the name of the Provider to be built.
|
||||
*/
|
||||
public ProviderBuilder setName(String name) {
|
||||
this.name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Causes the returned provider to provide an implementation of
|
||||
* {@link javax.net.ssl.TrustManagerFactory}.
|
||||
* @deprecated Use provideTrustManager(true)
|
||||
*/
|
||||
@Deprecated
|
||||
public ProviderBuilder provideTrustManager() {
|
||||
return provideTrustManager(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies whether the returned provider will provide an implementation of
|
||||
* {@link javax.net.ssl.TrustManagerFactory}.
|
||||
*/
|
||||
public ProviderBuilder provideTrustManager(boolean provide) {
|
||||
this.provideTrustManager = provide;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies what the default TLS protocol should be for SSLContext identifiers
|
||||
* {@code TLS}, {@code SSL}, and {@code Default}.
|
||||
*/
|
||||
public ProviderBuilder defaultTlsProtocol(String defaultTlsProtocol) {
|
||||
this.defaultTlsProtocol = defaultTlsProtocol;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Provider build() {
|
||||
return new OpenSSLProvider(name, provideTrustManager, defaultTlsProtocol);
|
||||
}
|
||||
}
|
||||
|
||||
public static ProviderBuilder newProviderBuilder() {
|
||||
return new ProviderBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum length (in bytes) of an encrypted packet.
|
||||
*/
|
||||
public static int maxEncryptedPacketLength() {
|
||||
return NativeConstants.SSL3_RT_MAX_PACKET_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default X.509 trust manager.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static X509TrustManager getDefaultX509TrustManager() throws KeyManagementException {
|
||||
checkAvailability();
|
||||
return SSLParametersImpl.getDefaultX509TrustManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link SSLContext} was created by this distribution of Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(SSLContext context) {
|
||||
return context.getProvider() instanceof OpenSSLProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new instance of the preferred {@link SSLContextSpi}.
|
||||
*/
|
||||
public static SSLContextSpi newPreferredSSLContextSpi() {
|
||||
checkAvailability();
|
||||
return OpenSSLContextImpl.getPreferred();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the client-side persistent cache to be used by the context.
|
||||
*/
|
||||
public static void setClientSessionCache(SSLContext context, SSLClientSessionCache cache) {
|
||||
SSLSessionContext clientContext = context.getClientSessionContext();
|
||||
if (!(clientContext instanceof ClientSessionContext)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt client context: " + clientContext.getClass().getName());
|
||||
}
|
||||
((ClientSessionContext) clientContext).setPersistentCache(cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the server-side persistent cache to be used by the context.
|
||||
*/
|
||||
public static void setServerSessionCache(SSLContext context, SSLServerSessionCache cache) {
|
||||
SSLSessionContext serverContext = context.getServerSessionContext();
|
||||
if (!(serverContext instanceof ServerSessionContext)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt client context: " + serverContext.getClass().getName());
|
||||
}
|
||||
((ServerSessionContext) serverContext).setPersistentCache(cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link SSLSocketFactory} was created by this distribution of
|
||||
* Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(SSLSocketFactory factory) {
|
||||
return factory instanceof OpenSSLSocketFactoryImpl;
|
||||
}
|
||||
|
||||
private static OpenSSLSocketFactoryImpl toConscrypt(SSLSocketFactory factory) {
|
||||
if (!isConscrypt(factory)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt socket factory: " + factory.getClass().getName());
|
||||
}
|
||||
return (OpenSSLSocketFactoryImpl) factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the default socket to be created for all socket factory instances.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setUseEngineSocketByDefault(boolean useEngineSocket) {
|
||||
OpenSSLSocketFactoryImpl.setUseEngineSocketByDefault(useEngineSocket);
|
||||
OpenSSLServerSocketFactoryImpl.setUseEngineSocketByDefault(useEngineSocket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the socket to be created for the given socket factory instance.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setUseEngineSocket(SSLSocketFactory factory, boolean useEngineSocket) {
|
||||
toConscrypt(factory).setUseEngineSocket(useEngineSocket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link SSLServerSocketFactory} was created by this distribution
|
||||
* of Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(SSLServerSocketFactory factory) {
|
||||
return factory instanceof OpenSSLServerSocketFactoryImpl;
|
||||
}
|
||||
|
||||
private static OpenSSLServerSocketFactoryImpl toConscrypt(SSLServerSocketFactory factory) {
|
||||
if (!isConscrypt(factory)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt server socket factory: " + factory.getClass().getName());
|
||||
}
|
||||
return (OpenSSLServerSocketFactoryImpl) factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the socket to be created for the given server socket factory instance.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setUseEngineSocket(SSLServerSocketFactory factory, boolean useEngineSocket) {
|
||||
toConscrypt(factory).setUseEngineSocket(useEngineSocket);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link SSLSocket} was created by this distribution of Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(SSLSocket socket) {
|
||||
return socket instanceof AbstractConscryptSocket;
|
||||
}
|
||||
|
||||
private static AbstractConscryptSocket toConscrypt(SSLSocket socket) {
|
||||
if (!isConscrypt(socket)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt socket: " + socket.getClass().getName());
|
||||
}
|
||||
return (AbstractConscryptSocket) socket;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method enables Server Name Indication (SNI) and overrides the hostname supplied
|
||||
* during socket creation. If the hostname is not a valid SNI hostname, the SNI extension
|
||||
* will be omitted from the handshake.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @param hostname the desired SNI hostname, or null to disable
|
||||
*/
|
||||
public static void setHostname(SSLSocket socket, String hostname) {
|
||||
toConscrypt(socket).setHostname(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either the hostname supplied during socket creation or via
|
||||
* {@link #setHostname(SSLSocket, String)}. No DNS resolution is attempted before
|
||||
* returning the hostname.
|
||||
*/
|
||||
public static String getHostname(SSLSocket socket) {
|
||||
return toConscrypt(socket).getHostname();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method attempts to create a textual representation of the peer host or IP. Does
|
||||
* not perform a reverse DNS lookup. This is typically used during session creation.
|
||||
*/
|
||||
public static String getHostnameOrIP(SSLSocket socket) {
|
||||
return toConscrypt(socket).getHostnameOrIP();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method enables session ticket support.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @param useSessionTickets True to enable session tickets
|
||||
*/
|
||||
public static void setUseSessionTickets(SSLSocket socket, boolean useSessionTickets) {
|
||||
toConscrypt(socket).setUseSessionTickets(useSessionTickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables TLS Channel ID for the given server-side socket.
|
||||
*
|
||||
* <p>This method needs to be invoked before the handshake starts.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @param enabled Whether to enable channel ID.
|
||||
* @throws IllegalStateException if this is a client socket or if the handshake has already
|
||||
* started.
|
||||
*/
|
||||
public static void setChannelIdEnabled(SSLSocket socket, boolean enabled) {
|
||||
toConscrypt(socket).setChannelIdEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TLS Channel ID for the given server-side socket. Channel ID is only available
|
||||
* once the handshake completes.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @return channel ID or {@code null} if not available.
|
||||
* @throws IllegalStateException if this is a client socket or if the handshake has not yet
|
||||
* completed.
|
||||
* @throws SSLException if channel ID is available but could not be obtained.
|
||||
*/
|
||||
public static byte[] getChannelId(SSLSocket socket) throws SSLException {
|
||||
return toConscrypt(socket).getChannelId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link PrivateKey} to be used for TLS Channel ID by this client socket.
|
||||
*
|
||||
* <p>This method needs to be invoked before the handshake starts.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @param privateKey private key (enables TLS Channel ID) or {@code null} for no key
|
||||
* (disables TLS Channel ID).
|
||||
* The private key must be an Elliptic Curve (EC) key based on the NIST P-256 curve (aka
|
||||
* SECG secp256r1 or ANSI
|
||||
* X9.62 prime256v1).
|
||||
* @throws IllegalStateException if this is a server socket or if the handshake has already
|
||||
* started.
|
||||
*/
|
||||
public static void setChannelIdPrivateKey(SSLSocket socket, PrivateKey privateKey) {
|
||||
toConscrypt(socket).setChannelIdPrivateKey(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ALPN protocol agreed upon by client and server.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @return the selected protocol or {@code null} if no protocol was agreed upon.
|
||||
*/
|
||||
public static String getApplicationProtocol(SSLSocket socket) {
|
||||
return toConscrypt(socket).getApplicationProtocol();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an application-provided ALPN protocol selector. If provided, this will override
|
||||
* the list of protocols set by {@link #setApplicationProtocols(SSLSocket, String[])}.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @param selector the ALPN protocol selector
|
||||
*/
|
||||
public static void setApplicationProtocolSelector(SSLSocket socket,
|
||||
ApplicationProtocolSelector selector) {
|
||||
toConscrypt(socket).setApplicationProtocolSelector(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the application-layer protocols (ALPN) in prioritization order.
|
||||
*
|
||||
* @param socket the socket being configured
|
||||
* @param protocols the protocols in descending order of preference. If empty, no protocol
|
||||
* indications will be used. This array will be copied.
|
||||
* @throws IllegalArgumentException - if protocols is null, or if any element in a non-empty
|
||||
* array is null or an empty (zero-length) string
|
||||
*/
|
||||
public static void setApplicationProtocols(SSLSocket socket, String[] protocols) {
|
||||
toConscrypt(socket).setApplicationProtocols(protocols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the application-layer protocols (ALPN) in prioritization order.
|
||||
*
|
||||
* @param socket the socket
|
||||
* @return the protocols in descending order of preference, or an empty array if protocol
|
||||
* indications are not being used. Always returns a new array.
|
||||
*/
|
||||
public static String[] getApplicationProtocols(SSLSocket socket) {
|
||||
return toConscrypt(socket).getApplicationProtocols();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tls-unique channel binding value for this connection, per RFC 5929. This
|
||||
* will return {@code null} if there is no such value available, such as if the handshake
|
||||
* has not yet completed or this connection is closed.
|
||||
*/
|
||||
public static byte[] getTlsUnique(SSLSocket socket) {
|
||||
return toConscrypt(socket).getTlsUnique();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a value derived from the TLS master secret as described in RFC 5705.
|
||||
*
|
||||
* @param label the label to use in calculating the exported value. This must be
|
||||
* an ASCII-only string.
|
||||
* @param context the application-specific context value to use in calculating the
|
||||
* exported value. This may be {@code null} to use no application context, which is
|
||||
* treated differently than an empty byte array.
|
||||
* @param length the number of bytes of keying material to return.
|
||||
* @return a value of the specified length, or {@code null} if the handshake has not yet
|
||||
* completed or the connection has been closed.
|
||||
* @throws SSLException if the value could not be exported.
|
||||
*/
|
||||
public static byte[] exportKeyingMaterial(SSLSocket socket, String label, byte[] context,
|
||||
int length) throws SSLException {
|
||||
return toConscrypt(socket).exportKeyingMaterial(label, context, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link SSLEngine} was created by this distribution of Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(SSLEngine engine) {
|
||||
return engine instanceof AbstractConscryptEngine;
|
||||
}
|
||||
|
||||
private static AbstractConscryptEngine toConscrypt(SSLEngine engine) {
|
||||
if (!isConscrypt(engine)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a conscrypt engine: " + engine.getClass().getName());
|
||||
}
|
||||
return (AbstractConscryptEngine) engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the given engine with the provided bufferAllocator.
|
||||
* @throws IllegalArgumentException if the provided engine is not a Conscrypt engine.
|
||||
* @throws IllegalStateException if the provided engine has already begun its handshake.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setBufferAllocator(SSLEngine engine, BufferAllocator bufferAllocator) {
|
||||
toConscrypt(engine).setBufferAllocator(bufferAllocator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides the given socket with the provided bufferAllocator. If the given socket is a
|
||||
* Conscrypt socket but does not use buffer allocators, this method does nothing.
|
||||
* @throws IllegalArgumentException if the provided socket is not a Conscrypt socket.
|
||||
* @throws IllegalStateException if the provided socket has already begun its handshake.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setBufferAllocator(SSLSocket socket, BufferAllocator bufferAllocator) {
|
||||
AbstractConscryptSocket s = toConscrypt(socket);
|
||||
if (s instanceof ConscryptEngineSocket) {
|
||||
((ConscryptEngineSocket) s).setBufferAllocator(bufferAllocator);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the default {@link BufferAllocator} to be used by all future
|
||||
* {@link SSLEngine} instances from this provider.
|
||||
*/
|
||||
@ExperimentalApi
|
||||
public static void setDefaultBufferAllocator(BufferAllocator bufferAllocator) {
|
||||
ConscryptEngine.setDefaultBufferAllocator(bufferAllocator);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method enables Server Name Indication (SNI) and overrides the hostname supplied
|
||||
* during engine creation.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @param hostname the desired SNI hostname, or {@code null} to disable
|
||||
*/
|
||||
public static void setHostname(SSLEngine engine, String hostname) {
|
||||
toConscrypt(engine).setHostname(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns either the hostname supplied during socket creation or via
|
||||
* {@link #setHostname(SSLEngine, String)}. No DNS resolution is attempted before
|
||||
* returning the hostname.
|
||||
*/
|
||||
public static String getHostname(SSLEngine engine) {
|
||||
return toConscrypt(engine).getHostname();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum overhead, in bytes, of sealing a record with SSL.
|
||||
*/
|
||||
public static int maxSealOverhead(SSLEngine engine) {
|
||||
return toConscrypt(engine).maxSealOverhead();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a listener on the given engine for completion of the TLS handshake
|
||||
*/
|
||||
public static void setHandshakeListener(SSLEngine engine, HandshakeListener handshakeListener) {
|
||||
toConscrypt(engine).setHandshakeListener(handshakeListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables/disables TLS Channel ID for the given server-side engine.
|
||||
*
|
||||
* <p>This method needs to be invoked before the handshake starts.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @param enabled Whether to enable channel ID.
|
||||
* @throws IllegalStateException if this is a client engine or if the handshake has already
|
||||
* started.
|
||||
*/
|
||||
public static void setChannelIdEnabled(SSLEngine engine, boolean enabled) {
|
||||
toConscrypt(engine).setChannelIdEnabled(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the TLS Channel ID for the given server-side engine. Channel ID is only available
|
||||
* once the handshake completes.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @return channel ID or {@code null} if not available.
|
||||
* @throws IllegalStateException if this is a client engine or if the handshake has not yet
|
||||
* completed.
|
||||
* @throws SSLException if channel ID is available but could not be obtained.
|
||||
*/
|
||||
public static byte[] getChannelId(SSLEngine engine) throws SSLException {
|
||||
return toConscrypt(engine).getChannelId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the {@link PrivateKey} to be used for TLS Channel ID by this client engine.
|
||||
*
|
||||
* <p>This method needs to be invoked before the handshake starts.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @param privateKey private key (enables TLS Channel ID) or {@code null} for no key
|
||||
* (disables TLS Channel ID).
|
||||
* The private key must be an Elliptic Curve (EC) key based on the NIST P-256 curve (aka
|
||||
* SECG secp256r1 or ANSI X9.62 prime256v1).
|
||||
* @throws IllegalStateException if this is a server engine or if the handshake has already
|
||||
* started.
|
||||
*/
|
||||
public static void setChannelIdPrivateKey(SSLEngine engine, PrivateKey privateKey) {
|
||||
toConscrypt(engine).setChannelIdPrivateKey(privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended unwrap method for multiple source and destination buffers.
|
||||
*
|
||||
* @param engine the target engine for the unwrap
|
||||
* @param srcs the source buffers
|
||||
* @param dsts the destination buffers
|
||||
* @return the result of the unwrap operation
|
||||
* @throws SSLException thrown if an SSL error occurred
|
||||
*/
|
||||
public static SSLEngineResult unwrap(SSLEngine engine, final ByteBuffer[] srcs,
|
||||
final ByteBuffer[] dsts) throws SSLException {
|
||||
return toConscrypt(engine).unwrap(srcs, dsts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exteneded unwrap method for multiple source and destination buffers.
|
||||
*
|
||||
* @param engine the target engine for the unwrap.
|
||||
* @param srcs the source buffers
|
||||
* @param srcsOffset the offset in the {@code srcs} array of the first source buffer
|
||||
* @param srcsLength the number of source buffers starting at {@code srcsOffset}
|
||||
* @param dsts the destination buffers
|
||||
* @param dstsOffset the offset in the {@code dsts} array of the first destination buffer
|
||||
* @param dstsLength the number of destination buffers starting at {@code dstsOffset}
|
||||
* @return the result of the unwrap operation
|
||||
* @throws SSLException thrown if an SSL error occurred
|
||||
*/
|
||||
public static SSLEngineResult unwrap(SSLEngine engine, final ByteBuffer[] srcs, int srcsOffset,
|
||||
final int srcsLength, final ByteBuffer[] dsts, final int dstsOffset,
|
||||
final int dstsLength) throws SSLException {
|
||||
return toConscrypt(engine).unwrap(
|
||||
srcs, srcsOffset, srcsLength, dsts, dstsOffset, dstsLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method enables session ticket support.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @param useSessionTickets True to enable session tickets
|
||||
*/
|
||||
public static void setUseSessionTickets(SSLEngine engine, boolean useSessionTickets) {
|
||||
toConscrypt(engine).setUseSessionTickets(useSessionTickets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the application-layer protocols (ALPN) in prioritization order.
|
||||
*
|
||||
* @param engine the engine being configured
|
||||
* @param protocols the protocols in descending order of preference. If empty, no protocol
|
||||
* indications will be used. This array will be copied.
|
||||
* @throws IllegalArgumentException - if protocols is null, or if any element in a non-empty
|
||||
* array is null or an empty (zero-length) string
|
||||
*/
|
||||
public static void setApplicationProtocols(SSLEngine engine, String[] protocols) {
|
||||
toConscrypt(engine).setApplicationProtocols(protocols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the application-layer protocols (ALPN) in prioritization order.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @return the protocols in descending order of preference, or an empty array if protocol
|
||||
* indications are not being used. Always returns a new array.
|
||||
*/
|
||||
public static String[] getApplicationProtocols(SSLEngine engine) {
|
||||
return toConscrypt(engine).getApplicationProtocols();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an application-provided ALPN protocol selector. If provided, this will override
|
||||
* the list of protocols set by {@link #setApplicationProtocols(SSLEngine, String[])}.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @param selector the ALPN protocol selector
|
||||
*/
|
||||
public static void setApplicationProtocolSelector(SSLEngine engine,
|
||||
ApplicationProtocolSelector selector) {
|
||||
toConscrypt(engine).setApplicationProtocolSelector(selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ALPN protocol agreed upon by client and server.
|
||||
*
|
||||
* @param engine the engine
|
||||
* @return the selected protocol or {@code null} if no protocol was agreed upon.
|
||||
*/
|
||||
public static String getApplicationProtocol(SSLEngine engine) {
|
||||
return toConscrypt(engine).getApplicationProtocol();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tls-unique channel binding value for this connection, per RFC 5929. This
|
||||
* will return {@code null} if there is no such value available, such as if the handshake
|
||||
* has not yet completed or this connection is closed.
|
||||
*/
|
||||
public static byte[] getTlsUnique(SSLEngine engine) {
|
||||
return toConscrypt(engine).getTlsUnique();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a value derived from the TLS master secret as described in RFC 5705.
|
||||
*
|
||||
* @param label the label to use in calculating the exported value. This must be
|
||||
* an ASCII-only string.
|
||||
* @param context the application-specific context value to use in calculating the
|
||||
* exported value. This may be {@code null} to use no application context, which is
|
||||
* treated differently than an empty byte array.
|
||||
* @param length the number of bytes of keying material to return.
|
||||
* @return a value of the specified length, or {@code null} if the handshake has not yet
|
||||
* completed or the connection has been closed.
|
||||
* @throws SSLException if the value could not be exported.
|
||||
*/
|
||||
public static byte[] exportKeyingMaterial(SSLEngine engine, String label, byte[] context,
|
||||
int length) throws SSLException {
|
||||
return toConscrypt(engine).exportKeyingMaterial(label, context, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates whether the given {@link TrustManager} was created by this distribution of
|
||||
* Conscrypt.
|
||||
*/
|
||||
public static boolean isConscrypt(TrustManager trustManager) {
|
||||
return trustManager instanceof TrustManagerImpl;
|
||||
}
|
||||
|
||||
private static TrustManagerImpl toConscrypt(TrustManager trustManager) {
|
||||
if (!isConscrypt(trustManager)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Not a Conscrypt trust manager: " + trustManager.getClass().getName());
|
||||
}
|
||||
return (TrustManagerImpl) trustManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default hostname verifier that will be used for HTTPS endpoint identification by
|
||||
* Conscrypt trust managers. If {@code null} (the default), endpoint identification will use
|
||||
* the default hostname verifier set in
|
||||
* {@link HttpsURLConnection#setDefaultHostnameVerifier(javax.net.ssl.HostnameVerifier)}.
|
||||
*/
|
||||
public synchronized static void setDefaultHostnameVerifier(ConscryptHostnameVerifier verifier) {
|
||||
TrustManagerImpl.setDefaultHostnameVerifier(verifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently-set default hostname verifier for Conscrypt trust managers.
|
||||
*
|
||||
* @see #setDefaultHostnameVerifier(ConscryptHostnameVerifier)
|
||||
*/
|
||||
public synchronized static ConscryptHostnameVerifier getDefaultHostnameVerifier(TrustManager trustManager) {
|
||||
return TrustManagerImpl.getDefaultHostnameVerifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the hostname verifier that will be used for HTTPS endpoint identification by the
|
||||
* given trust manager. If {@code null} (the default), endpoint identification will use the
|
||||
* default hostname verifier set in {@link #setDefaultHostnameVerifier(ConscryptHostnameVerifier)}.
|
||||
*
|
||||
* @throws IllegalArgumentException if the provided trust manager is not a Conscrypt trust
|
||||
* manager per {@link #isConscrypt(TrustManager)}
|
||||
*/
|
||||
public static void setHostnameVerifier(TrustManager trustManager, ConscryptHostnameVerifier verifier) {
|
||||
toConscrypt(trustManager).setHostnameVerifier(verifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently-set hostname verifier for the given trust manager.
|
||||
*
|
||||
* @throws IllegalArgumentException if the provided trust manager is not a Conscrypt trust
|
||||
* manager per {@link #isConscrypt(TrustManager)}
|
||||
*
|
||||
* @see #setHostnameVerifier(TrustManager, ConscryptHostnameVerifier)
|
||||
*/
|
||||
public static ConscryptHostnameVerifier getHostnameVerifier(TrustManager trustManager) {
|
||||
return toConscrypt(trustManager).getHostnameVerifier();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the HttpsURLConnection.HostnameVerifier into a ConscryptHostnameVerifier
|
||||
*/
|
||||
public static ConscryptHostnameVerifier wrapHostnameVerifier(final HostnameVerifier verifier) {
|
||||
return new ConscryptHostnameVerifier() {
|
||||
@Override
|
||||
public boolean verify(X509Certificate[] certificates, String hostname, SSLSession session) {
|
||||
return verifier.verify(hostname, session);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes
|
||||
|
||||
object AppCapabilities {
|
||||
@@ -17,7 +16,7 @@ object AppCapabilities {
|
||||
changeNumber = true,
|
||||
stories = true,
|
||||
giftBadges = true,
|
||||
pni = FeatureFlags.phoneNumberPrivacy(),
|
||||
pni = true,
|
||||
paymentActivation = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,10 +26,11 @@ import androidx.multidex.MultiDexApplication;
|
||||
|
||||
import com.google.android.gms.security.ProviderInstaller;
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.conscrypt.ConscryptSignal;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||
import org.signal.core.util.MemoryTracker;
|
||||
import org.signal.core.util.concurrent.AnrDetector;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -109,6 +110,7 @@ import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
import kotlin.Unit;
|
||||
import rxdogtag2.RxDogTag;
|
||||
|
||||
/**
|
||||
@@ -151,6 +153,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
initializeLogging();
|
||||
Log.i(TAG, "onCreate()");
|
||||
})
|
||||
.addBlocking("anr-detector", this::startAnrDetector)
|
||||
.addBlocking("security-provider", this::initializeSecurityProvider)
|
||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
@@ -165,7 +168,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("proxy-init", () -> {
|
||||
if (SignalStore.proxy().isProxyEnabled()) {
|
||||
Log.w(TAG, "Proxy detected. Enabling Conscrypt.setUseEngineSocketByDefault()");
|
||||
Conscrypt.setUseEngineSocketByDefault(true);
|
||||
ConscryptSignal.setUseEngineSocketByDefault(true);
|
||||
}
|
||||
})
|
||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||
@@ -227,6 +230,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
ExternalLaunchDonationJob.enqueueIfNecessary();
|
||||
FcmFetchManager.onForeground(this);
|
||||
startAnrDetector();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
@@ -260,6 +264,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getShakeToReport().disable();
|
||||
ApplicationDependencies.getDeadlockDetector().stop();
|
||||
MemoryTracker.stop();
|
||||
AnrDetector.stop();
|
||||
}
|
||||
|
||||
public void checkBuildExpiration() {
|
||||
@@ -269,6 +274,17 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: this is purposefully "started" twice -- once during application create, and once during foreground.
|
||||
* This is so we can capture ANR's that happen on boot before the foreground event.
|
||||
*/
|
||||
private void startAnrDetector() {
|
||||
AnrDetector.start(TimeUnit.SECONDS.toMillis(5), FeatureFlags::internalUser, (dumps) -> {
|
||||
LogDatabase.getInstance(this).anrs().save(System.currentTimeMillis(), dumps);
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeSecurityProvider() {
|
||||
int aesPosition = Security.insertProviderAt(new AesGcmProvider(), 1);
|
||||
Log.i(TAG, "Installed AesGcmProvider: " + aesPosition);
|
||||
@@ -278,7 +294,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
throw new ProviderInitializationException();
|
||||
}
|
||||
|
||||
int conscryptPosition = Security.insertProviderAt(Conscrypt.newProvider(), 2);
|
||||
int conscryptPosition = Security.insertProviderAt(ConscryptSignal.newProvider(), 2);
|
||||
Log.i(TAG, "Installed Conscrypt provider: " + conscryptPosition);
|
||||
|
||||
if (conscryptPosition < 0) {
|
||||
@@ -410,6 +426,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
if (FeatureFlags.callingFieldTrialAnyAddressPortsKillSwitch()) {
|
||||
fieldTrials.put("RingRTC-AnyAddressPortsKillSwitch", "Enabled");
|
||||
}
|
||||
if (!SignalStore.internalValues().callingDisableLBRed()) {
|
||||
fieldTrials.put("RingRTC-Audio-LBRed-For-Opus", "Enabled,bitrate_pri:22000");
|
||||
}
|
||||
CallManager.initialize(this, new RingRtcLogger(), fieldTrials);
|
||||
} catch (UnsatisfiedLinkError e) {
|
||||
throw new AssertionError("Unable to load ringrtc library", e);
|
||||
|
||||
@@ -50,6 +50,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.RxExtensions;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
@@ -70,6 +71,8 @@ import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository;
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.UsernameAciFetchResult;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
@@ -682,11 +685,18 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
return UsernameUtil.fetchAciForUsername(username);
|
||||
}, uuid -> {
|
||||
try {
|
||||
return RxExtensions.safeBlockingGet(UsernameRepository.fetchAciForUsername(UsernameUtil.sanitizeUsernameFromSearch(username)));
|
||||
} catch (InterruptedException e) {
|
||||
Log.w(TAG, "Interrupted?", e);
|
||||
return UsernameAciFetchResult.NetworkError.INSTANCE;
|
||||
}
|
||||
}, result -> {
|
||||
loadingDialog.dismiss();
|
||||
if (uuid.isPresent()) {
|
||||
Recipient recipient = Recipient.externalUsername(uuid.get(), username);
|
||||
|
||||
// TODO Could be more specific with errors
|
||||
if (result instanceof UsernameAciFetchResult.Success success) {
|
||||
Recipient recipient = Recipient.externalUsername(success.getAci(), username);
|
||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), username);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
|
||||
@@ -51,7 +51,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -313,7 +312,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
|
||||
if (!FeatureFlags.hideContacts() || recipient.isSelf() || recipient.isGroup()) {
|
||||
if (recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
@@ -228,7 +228,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getCreateProfileNameIntent() {
|
||||
Intent intent = EditProfileActivity.getIntentForUserProfile(this);
|
||||
Intent intent = CreateProfileActivity.getIntentForUserProfile(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.getDownloadManager
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FileUtils
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
@@ -37,7 +39,9 @@ object ApkUpdateInstaller {
|
||||
*/
|
||||
fun installOrPromptForInstall(context: Context, downloadId: Long, userInitiated: Boolean) {
|
||||
if (downloadId != SignalStore.apkUpdate().downloadId) {
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for! We likely have newer data. Ignoring.")
|
||||
Log.w(TAG, "DownloadId doesn't match the one we're waiting for (current: $downloadId, expected: ${SignalStore.apkUpdate().downloadId})! We likely have newer data. Ignoring.")
|
||||
ApkUpdateNotifications.dismissInstallPrompt(context)
|
||||
ApplicationDependencies.getJobManager().add(ApkUpdateJob())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,17 +90,6 @@ object ApkUpdateInstaller {
|
||||
Log.d(TAG, "Beginning APK install...")
|
||||
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||
|
||||
Log.d(TAG, "Clearing inactive sessions...")
|
||||
packageInstaller.mySessions
|
||||
.filter { session -> !session.isActive }
|
||||
.forEach { session ->
|
||||
try {
|
||||
packageInstaller.abandonSession(session.sessionId)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w(TAG, "Failed to abandon inactive session!", e)
|
||||
}
|
||||
}
|
||||
|
||||
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
|
||||
// At this point, we always want to set this if possible, since we've already prompted the user with our own notification when necessary.
|
||||
// This lets us skip the system-generated notification.
|
||||
@@ -151,8 +144,7 @@ object ApkUpdateInstaller {
|
||||
}
|
||||
|
||||
private fun shouldAutoUpdate(): Boolean {
|
||||
// TODO Auto-updates temporarily disabled. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
|
||||
return false
|
||||
// return Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
|
||||
// TODO Auto-updates temporarily restricted to nightlies. Once we have designs for allowing users to opt-out of auto-updates, we can re-enable this
|
||||
return Environment.IS_NIGHTLY && Build.VERSION.SDK_INT >= 31 && SignalStore.apkUpdate().autoUpdate && !ApplicationDependencies.getAppForegroundObserver().isForegrounded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ object ApkUpdateNotifications {
|
||||
*/
|
||||
@SuppressLint("LaunchActivityFromNotification")
|
||||
fun showInstallPrompt(context: Context, downloadId: Long) {
|
||||
Log.d(TAG, "Showing install prompt. DownloadId: $downloadId")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_FAILED_INSTALL)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
1,
|
||||
@@ -37,7 +40,7 @@ object ApkUpdateNotifications {
|
||||
action = ApkUpdateNotificationReceiver.ACTION_INITIATE_INSTALL
|
||||
putExtra(ApkUpdateNotificationReceiver.EXTRA_DOWNLOAD_ID, downloadId)
|
||||
},
|
||||
PendingIntentFlags.immutable()
|
||||
PendingIntentFlags.updateCurrent()
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
@@ -52,7 +55,15 @@ object ApkUpdateNotifications {
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_PROMPT_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun dismissInstallPrompt(context: Context) {
|
||||
Log.d(TAG, "Dismissing install prompt.")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
|
||||
}
|
||||
|
||||
fun showInstallFailed(context: Context, reason: FailureReason) {
|
||||
Log.d(TAG, "Showing failed notification. Reason: $reason")
|
||||
ServiceUtil.getNotificationManager(context).cancel(NotificationIds.APK_UPDATE_PROMPT_INSTALL)
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
@@ -66,11 +77,34 @@ object ApkUpdateNotifications {
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_FAILED_INSTALL, notification)
|
||||
}
|
||||
|
||||
fun showAutoUpdateSuccess(context: Context) {
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntentFlags.immutable()
|
||||
)
|
||||
|
||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NotificationChannels.getInstance().APP_UPDATES)
|
||||
.setContentTitle(context.getString(R.string.ApkUpdateNotifications_auto_update_success_title))
|
||||
.setContentText(context.getString(R.string.ApkUpdateNotifications_auto_update_success_body, appVersionName))
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setColor(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
ServiceUtil.getNotificationManager(context).notify(NotificationIds.APK_UPDATE_SUCCESSFUL_INSTALL, notification)
|
||||
}
|
||||
|
||||
enum class FailureReason {
|
||||
UNKNOWN,
|
||||
ABORTED,
|
||||
|
||||
@@ -12,6 +12,7 @@ import android.content.pm.PackageInstaller
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.apkupdate.ApkUpdateNotifications.FailureReason
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
/**
|
||||
* This is the receiver that is triggered by the [PackageInstaller] to notify of various events. Package installation is initiated
|
||||
@@ -34,8 +35,16 @@ class ApkUpdatePackageInstallerReceiver : BroadcastReceiver() {
|
||||
Log.w(TAG, "[onReceive] Status: $statusCode, Message: $statusMessage")
|
||||
|
||||
when (statusCode) {
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
if (SignalStore.apkUpdate().lastApkUploadTime != SignalStore.apkUpdate().pendingApkUploadTime) {
|
||||
Log.i(TAG, "Update installed successfully! Updating our lastApkUploadTime to ${SignalStore.apkUpdate().pendingApkUploadTime}")
|
||||
SignalStore.apkUpdate().lastApkUploadTime = SignalStore.apkUpdate().pendingApkUploadTime
|
||||
ApkUpdateNotifications.showAutoUpdateSuccess(context)
|
||||
} else {
|
||||
Log.i(TAG, "Spurious 'success' notification?")
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> handlePendingUserAction(context, userInitiated, intent!!)
|
||||
PackageInstaller.STATUS_SUCCESS -> Log.w(TAG, "Update installed successfully!")
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.ABORTED)
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.BLOCKED)
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> ApkUpdateNotifications.showInstallFailed(context, FailureReason.INCOMPATIBLE)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.BuildConfig;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob;
|
||||
import org.thoughtcrime.securesms.service.PersistentAlarmManagerListener;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -21,7 +22,7 @@ public class ApkUpdateRefreshListener extends PersistentAlarmManagerListener {
|
||||
|
||||
private static final String TAG = Log.tag(ApkUpdateRefreshListener.class);
|
||||
|
||||
private static final long INTERVAL = TimeUnit.HOURS.toMillis(6);
|
||||
private static final long INTERVAL = Environment.IS_NIGHTLY ? TimeUnit.HOURS.toMillis(2) : TimeUnit.HOURS.toMillis(6);
|
||||
|
||||
@Override
|
||||
protected long getNextScheduledExecutionTime(Context context) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
@@ -286,9 +287,9 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
override fun onProcessorActionProcessed() = Unit
|
||||
|
||||
override fun onUserCancelledPaymentFlow() {
|
||||
findNavController().popBackStack(R.id.giftFlowConfirmationFragment, false)
|
||||
}
|
||||
override fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney) = error("Unsupported operation")
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) = error("Unsupported operation")
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
SplashImage.register(adapter)
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(SplashImage.Model(R.drawable.ic_card_process))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__cant_process_subscription_payment, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
textPref(
|
||||
summary = DSLSettingsText.from(
|
||||
requireContext().getString(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__were_having_trouble),
|
||||
DSLSettingsText.LearnMoreModifier(ContextCompat.getColor(requireContext(), R.color.signal_accent_primary)) {
|
||||
CommunicationActions.openBrowserLink(requireContext(), requireContext().getString(R.string.donation_decline_code_error_url))
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(android.R.string.ok)
|
||||
) {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
|
||||
) {
|
||||
SignalStore.donationsValues().showCantProcessDialog = false
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.shouldRouteToGooglePay
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
val badge: Badge = args.badge
|
||||
val cancellationReason = UnexpectedSubscriptionCancellation.fromStatus(args.cancelationReason)
|
||||
val declineCode: StripeDeclineCode? = args.chargeFailure?.let { StripeDeclineCode.getFromCode(it) }
|
||||
val failureCode: StripeFailureCode? = args.chargeFailure?.let { StripeFailureCode.getFromCode(it) }
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
val inactive = cancellationReason == UnexpectedSubscriptionCancellation.INACTIVE
|
||||
|
||||
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
|
||||
} else if (declineCode != null) {
|
||||
getString(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
|
||||
getString(declineCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (failureCode != null) {
|
||||
getString(
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled_s,
|
||||
getString(failureCode.mapToErrorStringResource()),
|
||||
badge.name
|
||||
)
|
||||
} else if (inactive) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
|
||||
} else {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
if (badge.isSubscription() && declineCode?.shouldRouteToGooglePay() == true) {
|
||||
space(DimensionUnit.DP.toPixels(68f).toInt())
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__go_to_google_pay),
|
||||
onClick = {
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.google_pay_url))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
|
||||
}
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
}
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
|
||||
}
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
|
||||
}
|
||||
),
|
||||
onClick = {
|
||||
dismiss()
|
||||
if (isLikelyASustainer) {
|
||||
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
|
||||
} else {
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpiredBadgeBottomSheetDialogFragment::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
badge: Badge,
|
||||
cancellationReason: UnexpectedSubscriptionCancellation?,
|
||||
chargeFailure: ActiveSubscription.ChargeFailure?,
|
||||
fragmentManager: FragmentManager
|
||||
) {
|
||||
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
|
||||
val fragment = ExpiredBadgeBottomSheetDialogFragment()
|
||||
fragment.arguments = args.toBundle()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
/**
|
||||
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||
*/
|
||||
class ExpiredOneTimeBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
peekHeightPercentage = 1f
|
||||
) {
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredBadge.register(adapter)
|
||||
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments())
|
||||
val badge: Badge = args.badge
|
||||
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||
|
||||
Log.d(TAG, "Displaying Expired Badge Fragment with bundle: ${requireArguments()}", true)
|
||||
|
||||
return configure {
|
||||
customPref(ExpiredBadge.Model(badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
|
||||
}
|
||||
),
|
||||
onClick = {
|
||||
dismiss()
|
||||
if (isLikelyASustainer) {
|
||||
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
|
||||
} else {
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||
onClick = {
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ExpiredOneTimeBadgeBottomSheetDialogFragment::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun show(
|
||||
badge: Badge,
|
||||
cancellationReason: UnexpectedSubscriptionCancellation?,
|
||||
chargeFailure: ActiveSubscription.ChargeFailure?,
|
||||
fragmentManager: FragmentManager
|
||||
) {
|
||||
val args = ExpiredOneTimeBadgeBottomSheetDialogFragmentArgs.Builder(badge, cancellationReason?.status, chargeFailure?.code).build()
|
||||
val fragment = ExpiredOneTimeBadgeBottomSheetDialogFragment()
|
||||
fragment.arguments = args.toBundle()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package org.thoughtcrime.securesms.badges.self.expired
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.StripeDeclineCode
|
||||
import org.signal.donations.StripeFailureCode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImage112
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.mapToErrorStringResource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.ManageDonationsFragment
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
|
||||
class MonthlyDonationCanceledBottomSheetDialogFragment : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val chargeFailure: ActiveSubscription.ChargeFailure? = SignalStore.donationsValues().getUnexpectedSubscriptionCancelationChargeFailure()
|
||||
val declineCode: StripeDeclineCode = StripeDeclineCode.getFromCode(chargeFailure?.outcomeNetworkReason)
|
||||
val failureCode: StripeFailureCode = StripeFailureCode.getFromCode(chargeFailure?.code)
|
||||
|
||||
val errorMessage = if (declineCode.isKnown()) {
|
||||
declineCode.mapToErrorStringResource()
|
||||
} else if (failureCode.isKnown) {
|
||||
failureCode.mapToErrorStringResource()
|
||||
} else {
|
||||
declineCode.mapToErrorStringResource()
|
||||
}
|
||||
|
||||
MonthlyDonationCanceled(
|
||||
badge = SignalStore.donationsValues().getExpiredBadge(),
|
||||
errorMessageRes = errorMessage,
|
||||
onRenewClicked = {
|
||||
startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onNotNowClicked = {
|
||||
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = false
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = MonthlyDonationCanceledBottomSheetDialogFragment()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "ShortName", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun MonthlyDonationCanceledPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
MonthlyDonationCanceled(
|
||||
badge = Badge(
|
||||
id = "",
|
||||
category = Badge.Category.Donor,
|
||||
name = "Signal Star",
|
||||
description = "",
|
||||
imageUrl = Uri.EMPTY,
|
||||
imageDensity = "",
|
||||
expirationTimestamp = 0L,
|
||||
visible = true,
|
||||
duration = 0L
|
||||
),
|
||||
errorMessageRes = R.string.StripeFailureCode__verify_your_bank_details_are_correct,
|
||||
onRenewClicked = {},
|
||||
onNotNowClicked = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthlyDonationCanceled(
|
||||
badge: Badge?,
|
||||
@StringRes errorMessageRes: Int,
|
||||
onRenewClicked: () -> Unit,
|
||||
onNotNowClicked: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(horizontal = 34.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
if (badge != null) {
|
||||
Box(modifier = Modifier.padding(top = 21.dp, bottom = 16.dp)) {
|
||||
BadgeImage112(
|
||||
badge = badge,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
)
|
||||
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_error_circle_fill_24),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.error),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.background(
|
||||
color = SignalTheme.colors.colorSurface1,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.MonthlyDonationCanceled__title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface),
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val learnMore = stringResource(id = R.string.MonthlyDonationCanceled__learn_more)
|
||||
val errorMessage = stringResource(id = errorMessageRes)
|
||||
val fullString = stringResource(id = R.string.MonthlyDonationCanceled__message, errorMessage, learnMore)
|
||||
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, ManageDonationsFragment.DONATE_TROUBLESHOOTING_URL)
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = spanned,
|
||||
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
|
||||
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant),
|
||||
modifier = Modifier.padding(bottom = 36.dp)
|
||||
)
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onRenewClicked,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__renew_button))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = onNotNowClicked,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 56.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.MonthlyDonationCanceled__not_now_button))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ object CallLinks {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!url.startsWith(HTTPS_LINK_PREFIX) && !url.startsWith(SNGL_LINK_PREFIX)) {
|
||||
if (!url.startsWith(HTTPS_LINK_PREFIX) || !url.startsWith(SNGL_LINK_PREFIX)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,8 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -318,7 +317,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
} else if (messageRecord.isRateLimited()) {
|
||||
dateView.setText(R.string.ConversationItem_send_paused);
|
||||
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
|
||||
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
|
||||
dateView.setText(DateUtils.getOnlyTimeString(getContext(), ((MmsMessageRecord) messageRecord).getScheduledDate()));
|
||||
} else {
|
||||
long timestamp = messageRecord.getTimestamp();
|
||||
if (messageRecord.isEditMessage()) {
|
||||
@@ -417,7 +416,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
deliveryStatusView.setNone();
|
||||
} else if (messageRecord.isPending()) {
|
||||
deliveryStatusView.setPending();
|
||||
} else if (messageRecord.isRemoteRead()) {
|
||||
} else if (messageRecord.hasReadReceipt()) {
|
||||
deliveryStatusView.setRead();
|
||||
} else if (messageRecord.isDelivered()) {
|
||||
deliveryStatusView.setDelivered();
|
||||
@@ -434,7 +433,7 @@ public class ConversationItemFooter extends ConstraintLayout {
|
||||
if (mmsMessageRecord.getSlideDeck().getAudioSlide() != null) {
|
||||
showAudioDurationViews();
|
||||
|
||||
if (messageRecord.getViewedReceiptCount() > 0 || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
|
||||
if (messageRecord.isViewed() || (messageRecord.isOutgoing() && Objects.equals(messageRecord.getToRecipient(), Recipient.self()))) {
|
||||
revealDot.setProgress(1f);
|
||||
} else {
|
||||
revealDot.setProgress(0f);
|
||||
|
||||
@@ -42,7 +42,9 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), dialog!!.window!!)
|
||||
dialog?.window?.let { window ->
|
||||
WindowUtil.initializeScreenshotSecurity(requireContext(), window)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
|
||||
@@ -49,7 +49,7 @@ import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdap
|
||||
import org.thoughtcrime.securesms.conversation.MessageStyler;
|
||||
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
|
||||
import org.thoughtcrime.securesms.database.DraftTable;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageId;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
@@ -423,10 +423,10 @@ public class InputPanel extends ConstraintLayout
|
||||
}
|
||||
|
||||
private void updateEditModeThumbnail(@NonNull GlideRequests glideRequests) {
|
||||
if (messageToEdit instanceof MediaMmsMessageRecord) {
|
||||
MediaMmsMessageRecord mediaEditMessage = (MediaMmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
if (messageToEdit instanceof MmsMessageRecord) {
|
||||
MmsMessageRecord mediaEditMessage = (MmsMessageRecord) messageToEdit;
|
||||
SlideDeck slideDeck = mediaEditMessage.getSlideDeck();
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
|
||||
if (imageVideoSlide != null && imageVideoSlide.getUri() != null) {
|
||||
editMessageThumbnail.setVisibility(VISIBLE);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.components.reminder
|
||||
|
||||
import android.content.Context
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues.UsernameSyncState
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
|
||||
@@ -8,17 +10,31 @@ import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
* Displays a reminder message when the local username gets out of sync with
|
||||
* what the server thinks our username is.
|
||||
*/
|
||||
class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__something_went_wrong) {
|
||||
class UsernameOutOfSyncReminder : Reminder(NO_RESOURCE) {
|
||||
|
||||
init {
|
||||
val action = if (SignalStore.account().usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
|
||||
R.id.reminder_action_fix_username_and_link
|
||||
} else {
|
||||
R.id.reminder_action_fix_username_link
|
||||
}
|
||||
|
||||
addAction(
|
||||
Action(
|
||||
R.string.UsernameOutOfSyncReminder__fix_now,
|
||||
R.id.reminder_action_fix_username
|
||||
action
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getText(context: Context): CharSequence {
|
||||
return if (SignalStore.account().usernameSyncState == UsernameSyncState.USERNAME_AND_LINK_CORRUPTED) {
|
||||
context.getString(R.string.UsernameOutOfSyncReminder__username_and_link_corrupt)
|
||||
} else {
|
||||
context.getString(R.string.UsernameOutOfSyncReminder__link_corrupt)
|
||||
}
|
||||
}
|
||||
|
||||
override fun isDismissable(): Boolean {
|
||||
return false
|
||||
}
|
||||
@@ -26,7 +42,15 @@ class UsernameOutOfSyncReminder : Reminder(R.string.UsernameOutOfSyncReminder__s
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun isEligible(): Boolean {
|
||||
return FeatureFlags.usernames() && SignalStore.account().usernameOutOfSync
|
||||
return if (FeatureFlags.usernames()) {
|
||||
when (SignalStore.account().usernameSyncState) {
|
||||
UsernameSyncState.USERNAME_AND_LINK_CORRUPTED -> true
|
||||
UsernameSyncState.LINK_CORRUPTED -> true
|
||||
UsernameSyncState.IN_SYNC -> false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
)
|
||||
StartLocation.PRIVACY -> AppSettingsFragmentDirections.actionDirectToPrivacy()
|
||||
StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices()
|
||||
StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +189,9 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
@JvmStatic
|
||||
fun linkedDevices(context: Context): Intent = getIntentForStartLocation(context, StartLocation.LINKED_DEVICES)
|
||||
|
||||
@JvmStatic
|
||||
fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.USERNAME_LINK)
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -209,7 +213,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
CREATE_NOTIFICATION_PROFILE(10),
|
||||
NOTIFICATION_PROFILE_DETAILS(11),
|
||||
PRIVACY(12),
|
||||
LINKED_DEVICES(13);
|
||||
LINKED_DEVICES(13),
|
||||
USERNAME_LINK(14);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -27,10 +27,12 @@ import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.events.ReminderUpdateEvent
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity
|
||||
import org.thoughtcrime.securesms.util.Environment
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlayStoreUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
@@ -232,6 +234,16 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
)
|
||||
|
||||
if (Environment.IS_NIGHTLY) {
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("App updates"),
|
||||
icon = DSLSettingsIcon.from(R.drawable.symbol_calendar_24),
|
||||
onClick = {
|
||||
findNavController().safeNavigate(R.id.action_appSettingsFragment_to_appUpdatesSettingsFragment)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (SignalStore.paymentsValues().paymentsAvailability.showPaymentsMenu()) {
|
||||
@@ -347,7 +359,7 @@ class AppSettingsFragment : DSLSettingsFragment(
|
||||
summaryView.visibility = View.VISIBLE
|
||||
avatarView.visibility = View.VISIBLE
|
||||
|
||||
if (FeatureFlags.usernames()) {
|
||||
if (FeatureFlags.usernames() && SignalStore.account().usernameSyncState == AccountValues.UsernameSyncState.IN_SYNC) {
|
||||
qrButton.visibility = View.VISIBLE
|
||||
qrButton.isClickable = true
|
||||
qrButton.setOnClickListener { model.onQrButtonClicked() }
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.AppUtil
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
@@ -25,6 +26,7 @@ import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.JobDatabase
|
||||
import org.thoughtcrime.securesms.database.LocalMetricsDatabase
|
||||
import org.thoughtcrime.securesms.database.LogDatabase
|
||||
import org.thoughtcrime.securesms.database.MegaphoneDatabase
|
||||
@@ -48,13 +50,19 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.payments.DataExportUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__internal_preferences) {
|
||||
@@ -171,6 +179,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all logs"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).logs.clearAll()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared all logs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear keep longer logs"),
|
||||
onClick = {
|
||||
@@ -178,6 +197,28 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all crashes"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).crashes.clear()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared crashes", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Clear all ANRs"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
LogDatabase.getInstance(requireActivity().application).anrs.clear()
|
||||
}) {
|
||||
Toast.makeText(requireContext(), "Cleared ANRs", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Log dump PreKey ServiceId-KeyIds"),
|
||||
onClick = {
|
||||
@@ -185,6 +226,18 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Retry all jobs now"),
|
||||
summary = DSLSettingsText.from("Clear backoff intervals, app will restart"),
|
||||
onClick = {
|
||||
SimpleTask.run({
|
||||
JobDatabase.getInstance(ApplicationDependencies.getApplication()).debugResetBackoffInterval()
|
||||
}) {
|
||||
AppUtil.restart(requireContext())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("Payments"))
|
||||
@@ -439,9 +492,17 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
dividerPref()
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Disable LBRed"),
|
||||
isChecked = state.callingDisableLBRed,
|
||||
onClick = {
|
||||
viewModel.setInternalCallingDisableLBRed(!state.callingDisableLBRed)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
sectionHeaderPref(DSLSettingsText.from("Badges"))
|
||||
|
||||
clickPref(
|
||||
@@ -474,6 +535,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
Toast.makeText(context, "Cleared", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
}
|
||||
|
||||
if (state.hasPendingOneTimeDonation) {
|
||||
@@ -538,6 +601,13 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Add remote donate megaphone"),
|
||||
onClick = {
|
||||
viewModel.addRemoteDonateMegaphone()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(DSLSettingsText.from("CDS"))
|
||||
@@ -639,6 +709,47 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Corrupt username"),
|
||||
summary = DSLSettingsText.from("Changes our local username without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Corrupt your username?")
|
||||
.setMessage("Are you sure? You might not be able to get your original username back.")
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
val random = "${Hex.toStringCondensed(Util.getSecretBytes(4))}.${Random.nextInt(1, 100)}"
|
||||
|
||||
SignalStore.account().username = random
|
||||
SignalDatabase.recipients.setUsername(Recipient.self().id, random)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
|
||||
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Corrupt username link"),
|
||||
summary = DSLSettingsText.from("Changes our local username link without telling the server so it falls out of sync. Refresh profile afterwards to trigger corruption."),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Corrupt your username link?")
|
||||
.setMessage("Are you sure? You'll have to reset your link.")
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
SignalStore.account().usernameLink = UsernameLinkComponents(
|
||||
entropy = Util.getSecretBytes(32),
|
||||
serverId = SignalStore.account().usernameLink?.serverId ?: UUID.randomUUID()
|
||||
)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
Toast.makeText(context, "Done", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.show()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
sectionHeaderPref(DSLSettingsText.from("Chat Filters"))
|
||||
clickPref(
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord
|
||||
import org.thoughtcrime.securesms.database.model.addStyle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.emoji.EmojiFiles
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.CreateReleaseChannelJob
|
||||
import org.thoughtcrime.securesms.jobs.FetchRemoteMegaphoneImageJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
class InternalSettingsRepository(context: Context) {
|
||||
|
||||
@@ -58,4 +63,34 @@ class InternalSettingsRepository(context: Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
SignalExecutors.UNBOUNDED.execute {
|
||||
val record = RemoteMegaphoneRecord(
|
||||
uuid = UUID.randomUUID().toString(),
|
||||
priority = 100,
|
||||
countries = "*:1000000",
|
||||
minimumVersion = 1,
|
||||
doNotShowBefore = System.currentTimeMillis() - 2.days.inWholeMilliseconds,
|
||||
doNotShowAfter = System.currentTimeMillis() + 28.days.inWholeMilliseconds,
|
||||
showForNumberOfDays = 30,
|
||||
conditionalId = null,
|
||||
primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE,
|
||||
secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE,
|
||||
imageUrl = "/static/release-notes/donate-heart.png",
|
||||
title = "Donate Test",
|
||||
body = "Donate body test.",
|
||||
primaryActionText = "Donate",
|
||||
secondaryActionText = "Snooze",
|
||||
primaryActionData = null,
|
||||
secondaryActionData = JSONObject("{ \"snoozeDurationDays\": [5, 7, 100] }")
|
||||
)
|
||||
|
||||
SignalDatabase.remoteMegaphones.insert(record)
|
||||
|
||||
if (record.imageUrl != null) {
|
||||
ApplicationDependencies.getJobManager().add(FetchRemoteMegaphoneImageJob(record.uuid, record.imageUrl))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ data class InternalSettingsState(
|
||||
val callingAudioProcessingMethod: CallManager.AudioProcessingMethod,
|
||||
val callingDataMode: CallManager.DataMode,
|
||||
val callingDisableTelecom: Boolean,
|
||||
val callingDisableLBRed: Boolean,
|
||||
val useBuiltInEmojiSet: Boolean,
|
||||
val emojiVersion: EmojiFiles.Version?,
|
||||
val removeSenderKeyMinimium: Boolean,
|
||||
|
||||
@@ -113,6 +113,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setInternalCallingDisableLBRed(enabled: Boolean) {
|
||||
preferenceDataStore.putBoolean(InternalValues.CALLING_DISABLE_LBRED, enabled)
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun setUseConversationItemV2Media(enabled: Boolean) {
|
||||
SignalStore.internalValues().setUseConversationItemV2Media(enabled)
|
||||
refresh()
|
||||
@@ -122,6 +127,10 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
repository.addSampleReleaseNote()
|
||||
}
|
||||
|
||||
fun addRemoteDonateMegaphone() {
|
||||
repository.addRemoteDonateMegaphone()
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
store.update { getState().copy(emojiVersion = it.emojiVersion) }
|
||||
}
|
||||
@@ -138,6 +147,7 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
|
||||
callingAudioProcessingMethod = SignalStore.internalValues().callingAudioProcessingMethod(),
|
||||
callingDataMode = SignalStore.internalValues().callingDataMode(),
|
||||
callingDisableTelecom = SignalStore.internalValues().callingDisableTelecom(),
|
||||
callingDisableLBRed = SignalStore.internalValues().callingDisableLBRed(),
|
||||
useBuiltInEmojiSet = SignalStore.internalValues().forceBuiltInEmoji(),
|
||||
emojiVersion = null,
|
||||
removeSenderKeyMinimium = SignalStore.internalValues().removeSenderKeyMinimum(),
|
||||
|
||||
@@ -144,19 +144,21 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() {
|
||||
|
||||
private fun handleSubscriptionExpiration(state: InternalDonorErrorConfigurationState) {
|
||||
SignalStore.donationsValues().setExpiredBadge(state.selectedBadge)
|
||||
SignalStore.donationsValues().clearUserManuallyCancelled()
|
||||
handleSubscriptionPaymentFailure(state)
|
||||
}
|
||||
|
||||
private fun handleSubscriptionPaymentFailure(state: InternalDonorErrorConfigurationState) {
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationReason = state.selectedUnexpectedSubscriptionCancellation?.status
|
||||
SignalStore.donationsValues().unexpectedSubscriptionCancelationTimestamp = System.currentTimeMillis()
|
||||
SignalStore.donationsValues().showMonthlyDonationCanceledDialog = true
|
||||
SignalStore.donationsValues().setUnexpectedSubscriptionCancelationChargeFailure(
|
||||
state.selectedStripeDeclineCode?.let {
|
||||
ActiveSubscription.ChargeFailure(
|
||||
it.code,
|
||||
"Test Charge Failure",
|
||||
"Test Network Status",
|
||||
"Test Network Reason",
|
||||
it.code,
|
||||
"Test"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ private fun DonationPendingBottomSheetContent(
|
||||
text = stringResource(id = R.string.DonationPendingBottomSheet__donation_pending),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
|
||||
@@ -17,24 +17,8 @@ import java.math.BigDecimal
|
||||
import java.math.BigInteger
|
||||
import java.math.MathContext
|
||||
import java.util.Currency
|
||||
import kotlin.time.Duration.Companion.days
|
||||
|
||||
object DonationSerializationHelper {
|
||||
|
||||
private val PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT = 14.days
|
||||
private val PENDING_ONE_TIME_NORMAL_TIMEOUT = 1.days
|
||||
|
||||
val PendingOneTimeDonation.isExpired: Boolean
|
||||
get() {
|
||||
val timeout = if (paymentMethodType == PendingOneTimeDonation.PaymentMethodType.SEPA_DEBIT) {
|
||||
PENDING_ONE_TIME_BANK_TRANSFER_TIMEOUT
|
||||
} else {
|
||||
PENDING_ONE_TIME_NORMAL_TIMEOUT
|
||||
}
|
||||
|
||||
return (timestamp + timeout.inWholeMilliseconds) < System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun createPendingOneTimeDonationProto(
|
||||
badge: Badge,
|
||||
paymentSourceType: PaymentSourceType,
|
||||
|
||||
@@ -183,6 +183,7 @@ private fun DonationPaymentFailureBottomSheet(
|
||||
text = stringResource(id = R.string.DonationErrorBottomSheet__donation_couldnt_be_processed),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 45.dp)
|
||||
@@ -262,6 +263,7 @@ private fun DonationCompletedSheetContent(
|
||||
text = stringResource(id = R.string.DonationCompletedBottomSheet__donation_complete),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 45.dp)
|
||||
@@ -319,6 +321,7 @@ private fun DonationToggleRow(
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.align(Alignment.CenterVertically)
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheet
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPendingBottomSheetArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.thanks.ThanksForYourSupportBottomSheetDialogFragmentArgs
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
@@ -35,7 +38,7 @@ class TerminalDonationDelegate(
|
||||
for (donation in donations) {
|
||||
if (donation.isLongRunningPaymentMethod && (donation.error == null || donation.error.type != DonationErrorValue.Type.REDEMPTION)) {
|
||||
TerminalDonationBottomSheet.show(fragmentManager, donation)
|
||||
} else {
|
||||
} else if (donation.error != null) {
|
||||
lifecycleDisposable += badgeRepository.getBadge(donation).observeOn(AndroidSchedulers.mainThread()).subscribe { badge ->
|
||||
val args = ThanksForYourSupportBottomSheetDialogFragmentArgs.Builder(badge).build().toBundle()
|
||||
val sheet = ThanksForYourSupportBottomSheetDialogFragment()
|
||||
@@ -45,5 +48,12 @@ class TerminalDonationDelegate(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val verifiedMonthlyDonation: Stripe3DSData? = SignalStore.donationsValues().consumeVerifiedSubscription3DSData()
|
||||
if (verifiedMonthlyDonation != null) {
|
||||
DonationPendingBottomSheet().apply {
|
||||
arguments = DonationPendingBottomSheetArgs.Builder(verifiedMonthlyDonation.gatewayRequest).build().toBundle()
|
||||
}.show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.util.Projection
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
@@ -233,7 +234,6 @@ class DonateToSignalFragment :
|
||||
|
||||
customPref(
|
||||
DonationPillToggle.Model(
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
selected = state.donateToSignalType,
|
||||
onClick = {
|
||||
viewModel.toggleDonationType()
|
||||
@@ -256,23 +256,27 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__update_subscription),
|
||||
isEnabled = state.canUpdate,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
|
||||
FiatMoneyUtil.format(
|
||||
requireContext().resources,
|
||||
viewModel.getSelectedSubscriptionCost(),
|
||||
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__update_subscription_question)
|
||||
.setMessage(
|
||||
getString(
|
||||
R.string.SubscribeFragment__you_will_be_charged_the_full_amount_s_of,
|
||||
FiatMoneyUtil.format(
|
||||
requireContext().resources,
|
||||
viewModel.getSelectedSubscriptionCost(),
|
||||
FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
.setPositiveButton(R.string.SubscribeFragment__update) { _, _ ->
|
||||
viewModel.updateSubscription()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -282,28 +286,62 @@ class DonateToSignalFragment :
|
||||
text = DSLSettingsText.from(R.string.SubscribeFragment__cancel_subscription),
|
||||
isEnabled = state.areFieldsEnabled,
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
|
||||
.show()
|
||||
if (state.monthlyDonationState.transactionState.isTransactionJobPending) {
|
||||
showDonationPendingDialog(state)
|
||||
} else {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.SubscribeFragment__confirm_cancellation)
|
||||
.setMessage(R.string.SubscribeFragment__you_wont_be_charged_again)
|
||||
.setPositiveButton(R.string.SubscribeFragment__confirm) { _, _ ->
|
||||
viewModel.cancelSubscription()
|
||||
}
|
||||
.setNegativeButton(R.string.SubscribeFragment__not_now) { _, _ -> }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.DonateToSignalFragment__continue),
|
||||
isEnabled = state.canContinue,
|
||||
isEnabled = state.continueEnabled,
|
||||
onClick = {
|
||||
viewModel.requestSelectGateway()
|
||||
if (state.canContinue) {
|
||||
viewModel.requestSelectGateway()
|
||||
} else {
|
||||
showDonationPendingDialog(state)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDonationPendingDialog(state: DonateToSignalState) {
|
||||
val message = if (state.donateToSignalType == DonateToSignalType.ONE_TIME) {
|
||||
if (state.oneTimeDonationState.isOneTimeDonationLongRunning) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_onetime
|
||||
} else if (state.oneTimeDonationState.isNonVerifiedIdeal) {
|
||||
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_onetime
|
||||
}
|
||||
} else {
|
||||
if (state.monthlyDonationState.activeSubscription?.paymentMethod == ActiveSubscription.PAYMENT_METHOD_SEPA_DEBIT) {
|
||||
R.string.DonateToSignalFragment__bank_transfers_usually_take_1_business_day_to_process_monthly
|
||||
} else if (state.monthlyDonationState.nonVerifiedMonthlyDonation != null) {
|
||||
R.string.DonateToSignalFragment__your_ideal_payment_is_still_processing
|
||||
} else {
|
||||
R.string.DonateToSignalFragment__your_payment_is_still_being_processed_monthly
|
||||
}
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonateToSignalFragment__you_have_a_donation_pending)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.displayOneTimeSelection(areFieldsEnabled: Boolean, state: DonateToSignalState.OneTimeDonationState) {
|
||||
when (state.donationStage) {
|
||||
DonateToSignalState.DonationStage.INIT -> customPref(Boost.LoadingModel())
|
||||
@@ -337,11 +375,6 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.displayMonthlySelection(areFieldsEnabled: Boolean, state: DonateToSignalState.MonthlyDonationState) {
|
||||
if (state.transactionState.isTransactionJobPending) {
|
||||
customPref(Subscription.LoaderModel())
|
||||
return
|
||||
}
|
||||
|
||||
when (state.donationStage) {
|
||||
DonateToSignalState.DonationStage.INIT -> customPref(Subscription.LoaderModel())
|
||||
DonateToSignalState.DonationStage.FAILURE -> customPref(NetworkFailure.Model { viewModel.retryMonthlyDonationState() })
|
||||
@@ -437,10 +470,17 @@ class DonateToSignalFragment :
|
||||
viewModel.refreshActiveSubscription()
|
||||
}
|
||||
|
||||
override fun onUserCancelledPaymentFlow() {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
override fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney) {
|
||||
val max = FiatMoneyUtil.format(resources, sepaEuroMaximum, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonateToSignal__donation_amount_too_high)
|
||||
.setMessage(getString(R.string.DonateToSignalFragment__you_can_send_up_to_s_via_bank_transfer, max))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToDonationPendingBottomSheet(gatewayRequest))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.NonVerifiedMonthlyDonation
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.database.model.isLongRunning
|
||||
import org.thoughtcrime.securesms.database.model.isPending
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.subscription.Subscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
@@ -19,8 +23,8 @@ data class DonateToSignalState(
|
||||
|
||||
val areFieldsEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY && !monthlyDonationState.transactionState.isInProgress
|
||||
DonateToSignalType.ONE_TIME -> oneTimeDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.MONTHLY -> monthlyDonationState.donationStage == DonationStage.READY
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
@@ -59,13 +63,20 @@ data class DonateToSignalState(
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
val continueEnabled: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> areFieldsEnabled && oneTimeDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.MONTHLY -> areFieldsEnabled && monthlyDonationState.isSelectionValid && InAppDonations.hasAtLeastOnePaymentMethodAvailable()
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canContinue: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> continueEnabled && !oneTimeDonationState.isOneTimeDonationPending
|
||||
DonateToSignalType.MONTHLY -> continueEnabled && !monthlyDonationState.isSubscriptionActive && !monthlyDonationState.transactionState.isInProgress
|
||||
DonateToSignalType.GIFT -> error("This flow does not support gifts")
|
||||
}
|
||||
|
||||
val canUpdate: Boolean
|
||||
get() = when (donateToSignalType) {
|
||||
DonateToSignalType.ONE_TIME -> false
|
||||
@@ -85,9 +96,13 @@ data class DonateToSignalState(
|
||||
val isCustomAmountFocused: Boolean = false,
|
||||
val donationStage: DonationStage = DonationStage.INIT,
|
||||
val selectableCurrencyCodes: List<String> = emptyList(),
|
||||
val isOneTimeDonationPending: Boolean = SignalStore.donationsValues().getPendingOneTimeDonation() != null,
|
||||
private val pendingOneTimeDonation: PendingOneTimeDonation? = null,
|
||||
private val minimumDonationAmounts: Map<Currency, FiatMoney> = emptyMap()
|
||||
) {
|
||||
val isOneTimeDonationPending: Boolean = pendingOneTimeDonation.isPending()
|
||||
val isOneTimeDonationLongRunning: Boolean = pendingOneTimeDonation.isLongRunning()
|
||||
val isNonVerifiedIdeal = pendingOneTimeDonation?.pendingVerification == true
|
||||
|
||||
val minimumDonationAmountOfSelectedCurrency: FiatMoney = minimumDonationAmounts[selectedCurrency] ?: FiatMoney(BigDecimal.ZERO, selectedCurrency)
|
||||
private val isCustomAmountTooSmall: Boolean = if (isCustomAmountFocused) customAmount.amount < minimumDonationAmountOfSelectedCurrency.amount else false
|
||||
private val isCustomAmountZero: Boolean = customAmount.amount == BigDecimal.ZERO
|
||||
@@ -103,6 +118,7 @@ data class DonateToSignalState(
|
||||
val selectedSubscription: Subscription? = null,
|
||||
val donationStage: DonationStage = DonationStage.INIT,
|
||||
val selectableCurrencyCodes: List<String> = emptyList(),
|
||||
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
|
||||
val transactionState: TransactionState = TransactionState()
|
||||
) {
|
||||
val isSubscriptionActive: Boolean = _activeSubscription?.isActive == true
|
||||
|
||||
@@ -13,14 +13,16 @@ import org.signal.core.util.StringUtil
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.core.util.money.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.isExpired
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.boost.Boost
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobStatus
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.manage.DonationRedemptionJobWatcher
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.database.model.isExpired
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
@@ -34,6 +36,7 @@ import java.math.BigDecimal
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
import java.util.Currency
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Contains the logic to manage the UI of the unified donations screen.
|
||||
@@ -208,24 +211,31 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun initializeOneTimeDonationState(oneTimeDonationRepository: OneTimeDonationRepository) {
|
||||
val isOneTimeDonationInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
|
||||
it.map { jobState ->
|
||||
when (jobState) {
|
||||
JobTracker.JobState.PENDING -> true
|
||||
JobTracker.JobState.RUNNING -> true
|
||||
else -> false
|
||||
}
|
||||
}.orElse(false)
|
||||
val oneTimeDonationFromJob: Observable<Optional<PendingOneTimeDonation>> = DonationRedemptionJobWatcher.watchOneTimeRedemption().map {
|
||||
when (it) {
|
||||
is DonationRedemptionJobStatus.PendingExternalVerification -> Optional.ofNullable(it.pendingOneTimeDonation)
|
||||
|
||||
DonationRedemptionJobStatus.PendingReceiptRedemption,
|
||||
DonationRedemptionJobStatus.PendingReceiptRequest,
|
||||
DonationRedemptionJobStatus.FailedSubscription,
|
||||
DonationRedemptionJobStatus.None -> Optional.empty()
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val isOneTimeDonationPending: Observable<Boolean> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.map { pending -> pending.filter { !it.isExpired }.isPresent }
|
||||
val oneTimeDonationFromStore: Observable<Optional<PendingOneTimeDonation>> = SignalStore.donationsValues().observablePendingOneTimeDonation
|
||||
.map { pending -> pending.filter { !it.isExpired } }
|
||||
.distinctUntilChanged()
|
||||
|
||||
oneTimeDonationDisposables += Observable
|
||||
.combineLatest(isOneTimeDonationInProgress, isOneTimeDonationPending) { a, b -> a || b }
|
||||
.subscribe { hasPendingOneTimeDonation ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(isOneTimeDonationPending = hasPendingOneTimeDonation)) }
|
||||
.combineLatest(oneTimeDonationFromJob, oneTimeDonationFromStore) { job, store ->
|
||||
if (store.isPresent) {
|
||||
store
|
||||
} else {
|
||||
job
|
||||
}
|
||||
}
|
||||
.subscribe { pendingOneTimeDonation: Optional<PendingOneTimeDonation> ->
|
||||
store.update { it.copy(oneTimeDonationState = it.oneTimeDonationState.copy(pendingOneTimeDonation = pendingOneTimeDonation.orNull())) }
|
||||
}
|
||||
|
||||
oneTimeDonationDisposables += oneTimeDonationRepository.getBoostBadge().subscribeBy(
|
||||
@@ -295,23 +305,16 @@ class DonateToSignalViewModel(
|
||||
}
|
||||
|
||||
private fun monitorLevelUpdateProcessing() {
|
||||
val isTransactionJobInProgress: Observable<Boolean> = DonationRedemptionJobWatcher.watchSubscriptionRedemption().map {
|
||||
it.map { jobState ->
|
||||
when (jobState) {
|
||||
JobTracker.JobState.PENDING -> true
|
||||
JobTracker.JobState.RUNNING -> true
|
||||
else -> false
|
||||
}
|
||||
}.orElse(false)
|
||||
}
|
||||
val redemptionJobStatus: Observable<DonationRedemptionJobStatus> = DonationRedemptionJobWatcher.watchSubscriptionRedemption()
|
||||
|
||||
monthlyDonationDisposables += Observable
|
||||
.combineLatest(isTransactionJobInProgress, LevelUpdate.isProcessing, DonateToSignalState::TransactionState)
|
||||
.subscribeBy { transactionState ->
|
||||
.combineLatest(redemptionJobStatus, LevelUpdate.isProcessing, ::Pair)
|
||||
.subscribeBy { (jobStatus, levelUpdateProcessing) ->
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
monthlyDonationState = state.monthlyDonationState.copy(
|
||||
transactionState = transactionState
|
||||
nonVerifiedMonthlyDonation = if (jobStatus is DonationRedemptionJobStatus.PendingExternalVerification) jobStatus.nonVerifiedMonthlyDonation else null,
|
||||
transactionState = DonateToSignalState.TransactionState(jobStatus.isInProgress(), levelUpdateProcessing)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorParams
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
@@ -77,8 +79,12 @@ class DonationCheckoutDelegate(
|
||||
registerGooglePayCallback()
|
||||
|
||||
fragment.setFragmentResultListener(GatewaySelectorBottomSheet.REQUEST_KEY) { _, bundle ->
|
||||
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
|
||||
handleGatewaySelectionResponse(response)
|
||||
if (bundle.containsKey(GatewaySelectorBottomSheet.FAILURE_KEY)) {
|
||||
callback.showSepaEuroMaximumDialog(FiatMoney(bundle.getSerializable(GatewaySelectorBottomSheet.SEPA_EURO_MAX) as BigDecimal, CurrencyUtil.EURO))
|
||||
} else {
|
||||
val response: GatewayResponse = bundle.getParcelableCompat(GatewaySelectorBottomSheet.REQUEST_KEY, GatewayResponse::class.java)!!
|
||||
handleGatewaySelectionResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
fragment.setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
@@ -282,7 +288,7 @@ class DonationCheckoutDelegate(
|
||||
|
||||
if (throwable is DonationError.UserLaunchedExternalApplication) {
|
||||
Log.d(TAG, "User launched an external application.", true)
|
||||
|
||||
errorHandlerCallback?.onUserLaunchedAnExternalApplication()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,7 +336,7 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
|
||||
interface ErrorHandlerCallback {
|
||||
fun onUserCancelledPaymentFlow()
|
||||
fun onUserLaunchedAnExternalApplication()
|
||||
fun navigateToDonationPending(gatewayRequest: GatewayRequest)
|
||||
}
|
||||
|
||||
@@ -342,5 +348,6 @@ class DonationCheckoutDelegate(
|
||||
fun navigateToBankTransferMandate(gatewayResponse: GatewayResponse)
|
||||
fun onPaymentComplete(gatewayRequest: GatewayRequest)
|
||||
fun onProcessorActionProcessed()
|
||||
fun showSepaEuroMaximumDialog(sepaEuroMaximum: FiatMoney)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ data class CreditCardFormState(
|
||||
number,
|
||||
expiration.month.toInt(),
|
||||
expiration.year.toInt(),
|
||||
code.toInt()
|
||||
code
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Pa
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
/**
|
||||
@@ -65,22 +66,24 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
presentTitleAndSubtitle(requireContext(), args.request)
|
||||
|
||||
space(66.dp)
|
||||
space(16.dp)
|
||||
|
||||
if (state.loading) {
|
||||
space(16.dp)
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
space(16.dp)
|
||||
return@configure
|
||||
}
|
||||
|
||||
state.gatewayOrderStrategy.orderedGateways.forEachIndexed { index, gateway ->
|
||||
val isFirst = index == 0
|
||||
space(16.dp)
|
||||
|
||||
when (gateway) {
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state, isFirst)
|
||||
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state, isFirst)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state, isFirst)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state, isFirst)
|
||||
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state, isFirst)
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> renderGooglePayButton(state)
|
||||
GatewayResponse.Gateway.PAYPAL -> renderPayPalButton(state)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> renderCreditCardButton(state)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> renderSEPADebitButton(state)
|
||||
GatewayResponse.Gateway.IDEAL -> renderIDEALButton(state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,12 +91,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
private fun DSLConfiguration.renderGooglePayButton(state: GatewaySelectorState) {
|
||||
if (state.isGooglePayAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
customPref(
|
||||
GooglePayButton.Model(
|
||||
isEnabled = true,
|
||||
@@ -107,12 +106,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
private fun DSLConfiguration.renderPayPalButton(state: GatewaySelectorState) {
|
||||
if (state.isPayPalAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
customPref(
|
||||
PayPalButton.Model(
|
||||
onClick = {
|
||||
@@ -126,12 +121,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
private fun DSLConfiguration.renderCreditCardButton(state: GatewaySelectorState) {
|
||||
if (state.isCreditCardAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__credit_or_debit_card),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, R.color.signal_colorOnCustom),
|
||||
@@ -144,30 +135,31 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
private fun DSLConfiguration.renderSEPADebitButton(state: GatewaySelectorState) {
|
||||
if (state.isSEPADebitAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.bank_transfer),
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
if (state.sepaEuroMaximum != null &&
|
||||
args.request.fiat.currency == CurrencyUtil.EURO &&
|
||||
args.request.fiat.amount > state.sepaEuroMaximum.amount
|
||||
) {
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(FAILURE_KEY to true, SEPA_EURO_MAX to state.sepaEuroMaximum.amount))
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState, isFirstButton: Boolean) {
|
||||
private fun DSLConfiguration.renderIDEALButton(state: GatewaySelectorState) {
|
||||
if (state.isIDEALAvailable) {
|
||||
if (!isFirstButton) {
|
||||
space(8.dp)
|
||||
}
|
||||
|
||||
tonalButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__ideal),
|
||||
icon = DSLSettingsIcon.from(R.drawable.logo_ideal, NO_TINT),
|
||||
@@ -182,6 +174,8 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "payment_checkout_mode"
|
||||
const val FAILURE_KEY = "gateway_failure"
|
||||
const val SEPA_EURO_MAX = "sepa_euro_max"
|
||||
|
||||
fun DSLConfiguration.presentTitleAndSubtitle(context: Context, request: GatewayRequest) {
|
||||
when (request.donateToSignalType) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.getAvailablePaymentMethods
|
||||
import org.thoughtcrime.securesms.payments.currency.CurrencyUtil
|
||||
import org.whispersystems.signalservice.api.services.DonationsService
|
||||
import org.whispersystems.signalservice.internal.push.DonationsConfiguration
|
||||
import java.util.Locale
|
||||
@@ -9,12 +11,12 @@ import java.util.Locale
|
||||
class GatewaySelectorRepository(
|
||||
private val donationsService: DonationsService
|
||||
) {
|
||||
fun getAvailableGateways(currencyCode: String): Single<Set<GatewayResponse.Gateway>> {
|
||||
fun getAvailableGatewayConfiguration(currencyCode: String): Single<GatewayConfiguration> {
|
||||
return Single.fromCallable {
|
||||
donationsService.getDonationsConfiguration(Locale.getDefault())
|
||||
}.flatMap { it.flattenResult() }
|
||||
.map { configuration ->
|
||||
configuration.getAvailablePaymentMethods(currencyCode).map {
|
||||
val available = configuration.getAvailablePaymentMethods(currencyCode).map {
|
||||
when (it) {
|
||||
DonationsConfiguration.PAYPAL -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
DonationsConfiguration.CARD -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
@@ -23,6 +25,16 @@ class GatewaySelectorRepository(
|
||||
else -> listOf()
|
||||
}
|
||||
}.flatten().toSet()
|
||||
|
||||
GatewayConfiguration(
|
||||
availableGateways = available,
|
||||
sepaEuroMaximum = if (configuration.sepaMaximumEuros != null) FiatMoney(configuration.sepaMaximumEuros, CurrencyUtil.EURO) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class GatewayConfiguration(
|
||||
val availableGateways: Set<GatewayResponse.Gateway>,
|
||||
val sepaEuroMaximum: FiatMoney?
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
|
||||
data class GatewaySelectorState(
|
||||
@@ -10,5 +11,6 @@ data class GatewaySelectorState(
|
||||
val isPayPalAvailable: Boolean = false,
|
||||
val isCreditCardAvailable: Boolean = false,
|
||||
val isSEPADebitAvailable: Boolean = false,
|
||||
val isIDEALAvailable: Boolean = false
|
||||
val isIDEALAvailable: Boolean = false,
|
||||
val sepaEuroMaximum: FiatMoney? = null
|
||||
)
|
||||
|
||||
@@ -36,17 +36,18 @@ class GatewaySelectorViewModel(
|
||||
|
||||
init {
|
||||
val isGooglePayAvailable = repository.isGooglePayAvailable().toSingleDefault(true).onErrorReturnItem(false)
|
||||
val availabilitySet = gatewaySelectorRepository.getAvailableGateways(currencyCode = args.request.currencyCode)
|
||||
disposables += Single.zip(isGooglePayAvailable, availabilitySet, ::Pair).subscribeBy { (googlePayAvailable, gatewaysAvailable) ->
|
||||
val gatewayConfiguration = gatewaySelectorRepository.getAvailableGatewayConfiguration(currencyCode = args.request.currencyCode)
|
||||
disposables += Single.zip(isGooglePayAvailable, gatewayConfiguration, ::Pair).subscribeBy { (googlePayAvailable, gatewayConfiguration) ->
|
||||
SignalStore.donationsValues().isGooglePayReady = googlePayAvailable
|
||||
store.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
isCreditCardAvailable = it.isCreditCardAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.CREDIT_CARD),
|
||||
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.GOOGLE_PAY),
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT),
|
||||
isIDEALAvailable = it.isIDEALAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.IDEAL)
|
||||
isCreditCardAvailable = it.isCreditCardAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.CREDIT_CARD),
|
||||
isGooglePayAvailable = it.isGooglePayAvailable && googlePayAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.GOOGLE_PAY),
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.SEPA_DEBIT),
|
||||
isIDEALAvailable = it.isIDEALAvailable && gatewayConfiguration.availableGateways.contains(GatewayResponse.Gateway.IDEAL),
|
||||
sepaEuroMaximum = gatewayConfiguration.sepaEuroMaximum
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ data class Stripe3DSData(
|
||||
@IgnoredOnParcel
|
||||
val paymentSourceType: PaymentSourceType = PaymentSourceType.fromCode(rawPaymentSourceType)
|
||||
|
||||
@IgnoredOnParcel
|
||||
val isLongRunning: Boolean = paymentSourceType == PaymentSourceType.Stripe.SEPADebit || (gatewayRequest.donateToSignalType == DonateToSignalType.MONTHLY && paymentSourceType.isBankTransfer)
|
||||
|
||||
fun toProtoBytes(): ByteArray {
|
||||
return ExternalLaunchTransactionState(
|
||||
stripeIntentAccessor = ExternalLaunchTransactionState.StripeIntentAccessor(
|
||||
|
||||
@@ -5,23 +5,29 @@ import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.ComponentDialog
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationWebViewOnBackPressedCallback
|
||||
import org.thoughtcrime.securesms.databinding.DonationWebviewFragmentBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
@@ -35,6 +41,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
}
|
||||
|
||||
val binding by ViewBinderDelegate(DonationWebviewFragmentBinding::bind) {
|
||||
it.webView.webViewClient = WebViewClient()
|
||||
it.webView.clearCache(true)
|
||||
it.webView.clearHistory()
|
||||
}
|
||||
@@ -48,7 +55,7 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
@SuppressLint("SetJavaScriptEnabled", "SetTextI18n")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
dialog!!.window!!.setFlags(
|
||||
WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
|
||||
@@ -68,6 +75,19 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
binding.webView
|
||||
)
|
||||
)
|
||||
|
||||
if (FeatureFlags.internalUser() && args.stripe3DSData.paymentSourceType == PaymentSourceType.Stripe.IDEAL) {
|
||||
val openApp = MaterialButton(requireContext()).apply {
|
||||
text = "Open App"
|
||||
layoutParams = FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
|
||||
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
setOnClickListener {
|
||||
handleLaunchExternal(Intent(Intent.ACTION_VIEW, args.uri))
|
||||
}
|
||||
}
|
||||
binding.root.addView(openApp)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
@@ -78,14 +98,13 @@ class Stripe3DSDialogFragment : DialogFragment(R.layout.donation_webview_fragmen
|
||||
}
|
||||
|
||||
private fun handleLaunchExternal(intent: Intent) {
|
||||
startActivity(intent)
|
||||
|
||||
SignalStore.donationsValues().setPending3DSData(args.stripe3DSData)
|
||||
|
||||
result = bundleOf(
|
||||
LAUNCHED_EXTERNAL to true
|
||||
)
|
||||
|
||||
startActivity(intent)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
@@ -156,13 +158,15 @@ class BankTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate.
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUserCancelledPaymentFlow() = Unit
|
||||
override fun onUserLaunchedAnExternalApplication() = Unit
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().popBackStack()
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +274,7 @@ private fun BankTransferDetailsContent(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
@@ -289,9 +294,11 @@ private fun BankTransferDetailsContent(
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
supportingText = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -309,16 +316,20 @@ private fun BankTransferDetailsContent(
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onDonateClick() }
|
||||
),
|
||||
supportingText = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { setDisplayFindAccountInfoSheet(true) }
|
||||
@@ -334,7 +345,7 @@ private fun BankTransferDetailsContent(
|
||||
onClick = onDonateClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Text(text = donateLabel)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ enum class IdealBank(
|
||||
REVOLUT("revolut"),
|
||||
SNS_BANK("sns_bank"),
|
||||
TRIODOS_BANK("triodos_bank"),
|
||||
VAN_LANCHOT("van_lanchot"),
|
||||
VAN_LANSCHOT("van_lanschot"),
|
||||
YOURSAFE("yoursafe");
|
||||
|
||||
fun getUIValues(): UIValues = bankToUIValues[this]!!
|
||||
@@ -83,8 +83,8 @@ enum class IdealBank(
|
||||
name = R.string.IdealBank__triodos_bank,
|
||||
icon = R.drawable.ideal_triodos_bank
|
||||
),
|
||||
VAN_LANCHOT to UIValues(
|
||||
name = R.string.IdealBank__van_lanchot,
|
||||
VAN_LANSCHOT to UIValues(
|
||||
name = R.string.IdealBank__van_lanschot,
|
||||
icon = R.drawable.ideal_van_lanschot
|
||||
),
|
||||
YOURSAFE to UIValues(
|
||||
|
||||
@@ -42,6 +42,8 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
@@ -147,13 +149,21 @@ class IdealTransferDetailsFragment : ComposeFragment(), DonationCheckoutDelegate
|
||||
)
|
||||
}
|
||||
|
||||
override fun onUserCancelledPaymentFlow() = Unit
|
||||
override fun onUserLaunchedAnExternalApplication() {
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun navigateToDonationPending(gatewayRequest: GatewayRequest) {
|
||||
findNavController().popBackStack()
|
||||
findNavController().popBackStack()
|
||||
|
||||
setFragmentResult(BankTransferRequestKeys.PENDING_KEY, bundleOf(BankTransferRequestKeys.PENDING_KEY to gatewayRequest))
|
||||
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
findNavController().popBackStack(R.id.donateToSignalFragment, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +231,6 @@ private fun IdealTransferDetailsContent(
|
||||
onSelectBankClick = onSelectBankClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,9 +248,11 @@ private fun IdealTransferDetailsContent(
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
supportingText = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,9 +274,11 @@ private fun IdealTransferDetailsContent(
|
||||
}
|
||||
}
|
||||
),
|
||||
supportingText = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.padding(top = 16.dp)
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -329,7 +342,9 @@ private fun IdealBankSelector(
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledIndicatorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
supportingText = {},
|
||||
modifier = modifier
|
||||
.defaultMinSize(minHeight = 78.dp)
|
||||
.clickable(
|
||||
onClick = onSelectBankClick,
|
||||
role = Role.Button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -12,41 +13,54 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.animateScrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.donations.PaymentSourceType
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayResponse
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorNestedScrollConnection
|
||||
import org.thoughtcrime.securesms.compose.StatusBarColorAnimator
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
@@ -61,15 +75,15 @@ class BankTransferMandateFragment : ComposeFragment() {
|
||||
BankTransferMandateViewModel(PaymentSourceType.Stripe.SEPADebit)
|
||||
}
|
||||
|
||||
private lateinit var statusBarColorNestedScrollConnection: StatusBarColorNestedScrollConnection
|
||||
private lateinit var statusBarColorAnimator: StatusBarColorAnimator
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
statusBarColorNestedScrollConnection = StatusBarColorNestedScrollConnection(requireActivity())
|
||||
statusBarColorAnimator = StatusBarColorAnimator(requireActivity())
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
statusBarColorNestedScrollConnection.setColorImmediate()
|
||||
statusBarColorAnimator.setColorImmediate()
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -83,7 +97,7 @@ class BankTransferMandateFragment : ComposeFragment() {
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onContinueClick = this::onContinueClick,
|
||||
onLearnMoreClick = this::onLearnMoreClick,
|
||||
modifier = Modifier.nestedScroll(statusBarColorNestedScrollConnection)
|
||||
onCanScrollUp = statusBarColorAnimator::setCanScrollUp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -119,11 +133,14 @@ fun BankTransferScreenPreview() {
|
||||
failedToLoadMandate = false,
|
||||
onNavigationClick = {},
|
||||
onContinueClick = {},
|
||||
onLearnMoreClick = {}
|
||||
onLearnMoreClick = {},
|
||||
onCanScrollUp = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@Composable
|
||||
fun BankTransferScreen(
|
||||
bankMandate: String,
|
||||
@@ -131,91 +148,126 @@ fun BankTransferScreen(
|
||||
onNavigationClick: () -> Unit,
|
||||
onContinueClick: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
onCanScrollUp: (Boolean) -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24)),
|
||||
titleContent = { contentOffset, title ->
|
||||
AnimatedVisibility(
|
||||
visible = contentOffset < 0f,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bank_transfer),
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(
|
||||
SignalTheme.colors.colorSurface2,
|
||||
CircleShape
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
AnimatedVisibility(
|
||||
visible = listState.canScrollBackward,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer), style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onNavigationClick,
|
||||
Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24)),
|
||||
contentDescription = null
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = if (listState.canScrollBackward) TopAppBarDefaults.topAppBarColors(containerColor = SignalTheme.colors.colorSurface2) else TopAppBarDefaults.topAppBarColors()
|
||||
)
|
||||
}
|
||||
) {
|
||||
onCanScrollUp(listState.canScrollBackward)
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = CenterHorizontally, modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f, true)
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.bank_transfer),
|
||||
contentScale = ContentScale.FillBounds,
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(
|
||||
SignalTheme.colors.colorSurface2,
|
||||
CircleShape
|
||||
)
|
||||
.padding(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
onLearnMoreClick()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 12.dp)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
|
||||
)
|
||||
}
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, ""),
|
||||
onUrlClick = {
|
||||
onLearnMoreClick()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(bottom = 12.dp)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter))
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
|
||||
)
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = if (failedToLoadMandate) stringResource(id = R.string.BankTransferMandateFragment__failed_to_load_mandate) else bankMandate,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.bank_transfer_mandate_gutter), vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!failedToLoadMandate) {
|
||||
item {
|
||||
Surface(
|
||||
shadowElevation = if (listState.canScrollForward) 8.dp else 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
onClick = {
|
||||
if (!listState.canScrollForward) {
|
||||
onContinueClick()
|
||||
} else {
|
||||
scope.launch {
|
||||
listState.animateScrollBy(value = 1000f)
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 46.dp)
|
||||
.wrapContentWidth()
|
||||
.padding(top = 16.dp, bottom = 16.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
|
||||
Text(text = if (listState.canScrollForward) stringResource(id = R.string.BankTransferMandateFragment__read_more) else stringResource(id = R.string.BankTransferMandateFragment__agree))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,17 +31,17 @@ fun StripeFailureCode.mapToErrorStringResource(): Int {
|
||||
fun StripeDeclineCode.mapToErrorStringResource(): Int {
|
||||
return when (this) {
|
||||
is StripeDeclineCode.Known -> when (this.code) {
|
||||
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again
|
||||
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_payment_method_is_up_to_date_in_google_pay_and_try_again_if_the_problem
|
||||
StripeDeclineCode.Code.APPROVE_WITH_ID -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again
|
||||
StripeDeclineCode.Code.CALL_ISSUER -> R.string.DeclineCode__verify_your_card_details_are_correct_and_try_again_if_the_problem_continues
|
||||
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> R.string.DeclineCode__your_card_does_not_support_this_type_of_purchase
|
||||
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired
|
||||
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
|
||||
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
|
||||
StripeDeclineCode.Code.EXPIRED_CARD -> R.string.DeclineCode__your_card_has_expired_verify_your_card_details
|
||||
StripeDeclineCode.Code.INCORRECT_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
|
||||
StripeDeclineCode.Code.INCORRECT_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
|
||||
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> R.string.DeclineCode__your_card_does_not_have_sufficient_funds
|
||||
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year
|
||||
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect
|
||||
StripeDeclineCode.Code.INVALID_CVC -> R.string.DeclineCode__your_cards_cvc_number_is_incorrect_verify_your_card_details
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> R.string.DeclineCode__the_expiration_month_on_your_card_is_incorrect
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> R.string.DeclineCode__the_expiration_year_on_your_card_is_incorrect
|
||||
StripeDeclineCode.Code.INVALID_NUMBER -> R.string.DeclineCode__your_card_number_is_incorrect_verify_your_card_details
|
||||
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> R.string.DeclineCode__try_completing_the_payment_again
|
||||
StripeDeclineCode.Code.PROCESSING_ERROR -> R.string.DeclineCode__try_again
|
||||
StripeDeclineCode.Code.REENTER_TRANSACTION -> R.string.DeclineCode__try_again
|
||||
@@ -50,26 +50,3 @@ fun StripeDeclineCode.mapToErrorStringResource(): Int {
|
||||
else -> R.string.DeclineCode__try_another_payment_method_or_contact_your_bank
|
||||
}
|
||||
}
|
||||
|
||||
fun StripeDeclineCode.shouldRouteToGooglePay(): Boolean {
|
||||
return when (this) {
|
||||
is StripeDeclineCode.Known -> when (this.code) {
|
||||
StripeDeclineCode.Code.APPROVE_WITH_ID -> true
|
||||
StripeDeclineCode.Code.CALL_ISSUER -> true
|
||||
StripeDeclineCode.Code.CARD_NOT_SUPPORTED -> false
|
||||
StripeDeclineCode.Code.EXPIRED_CARD -> true
|
||||
StripeDeclineCode.Code.INCORRECT_NUMBER -> true
|
||||
StripeDeclineCode.Code.INCORRECT_CVC -> true
|
||||
StripeDeclineCode.Code.INSUFFICIENT_FUNDS -> false
|
||||
StripeDeclineCode.Code.INVALID_CVC -> true
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_MONTH -> true
|
||||
StripeDeclineCode.Code.INVALID_EXPIRY_YEAR -> true
|
||||
StripeDeclineCode.Code.INVALID_NUMBER -> true
|
||||
StripeDeclineCode.Code.ISSUER_NOT_AVAILABLE -> false
|
||||
StripeDeclineCode.Code.PROCESSING_ERROR -> false
|
||||
StripeDeclineCode.Code.REENTER_TRANSACTION -> false
|
||||
else -> false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ object ActiveSubscriptionPreference {
|
||||
val subscription: Subscription,
|
||||
val renewalTimestamp: Long = -1L,
|
||||
val redemptionState: ManageDonationsState.RedemptionState,
|
||||
val activeSubscription: ActiveSubscription.Subscription,
|
||||
val activeSubscription: ActiveSubscription.Subscription?,
|
||||
val onContactSupport: () -> Unit,
|
||||
val onPendingClick: (FiatMoney) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
@@ -104,7 +104,7 @@ object ActiveSubscriptionPreference {
|
||||
}
|
||||
|
||||
private fun presentFailureState(model: Model) {
|
||||
if (model.activeSubscription.isFailedPayment || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
|
||||
if (model.activeSubscription?.isFailedPayment == true || SignalStore.donationsValues().shouldCancelSubscriptionBeforeNextSubscribeAttempt) {
|
||||
presentPaymentFailureState(model)
|
||||
} else {
|
||||
presentRedemptionFailureState(model)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
|
||||
/**
|
||||
* Represent the status of a donation as represented in the job system.
|
||||
*/
|
||||
sealed class DonationRedemptionJobStatus {
|
||||
/**
|
||||
* No pending/running jobs for a donation type.
|
||||
*/
|
||||
object None : DonationRedemptionJobStatus()
|
||||
|
||||
/**
|
||||
* Donation is pending external user verification (e.g., iDEAL).
|
||||
*
|
||||
* For one-time, pending donation data is provided via the job data as it is not in the store yet.
|
||||
*/
|
||||
class PendingExternalVerification(
|
||||
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
|
||||
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null
|
||||
) : DonationRedemptionJobStatus()
|
||||
|
||||
/**
|
||||
* Donation is at the receipt request status.
|
||||
*
|
||||
* For one-time donations, pending donation data available via the store.
|
||||
*/
|
||||
object PendingReceiptRequest : DonationRedemptionJobStatus()
|
||||
|
||||
/**
|
||||
* Donation is at the receipt redemption status.
|
||||
*
|
||||
* For one-time donations, pending donation data available via the store.
|
||||
*/
|
||||
object PendingReceiptRedemption : DonationRedemptionJobStatus()
|
||||
|
||||
/**
|
||||
* Representation of a failed subscription job chain derived from no pending/running jobs and
|
||||
* a failure state in the store.
|
||||
*/
|
||||
object FailedSubscription : DonationRedemptionJobStatus()
|
||||
|
||||
fun isInProgress(): Boolean {
|
||||
return when (this) {
|
||||
is PendingExternalVerification,
|
||||
PendingReceiptRedemption,
|
||||
PendingReceiptRequest -> true
|
||||
|
||||
FailedSubscription,
|
||||
None -> false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.Stripe3DSData
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec
|
||||
import org.thoughtcrime.securesms.jobs.BoostReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
|
||||
import org.thoughtcrime.securesms.jobs.ExternalLaunchDonationJob
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionReceiptRequestResponseJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.util.Optional
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
@@ -21,22 +24,38 @@ object DonationRedemptionJobWatcher {
|
||||
ONE_TIME
|
||||
}
|
||||
|
||||
fun watchSubscriptionRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.SUBSCRIPTION)
|
||||
fun watchSubscriptionRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.SUBSCRIPTION)
|
||||
|
||||
fun watchOneTimeRedemption(): Observable<Optional<JobTracker.JobState>> = watch(RedemptionType.ONE_TIME)
|
||||
@JvmStatic
|
||||
@WorkerThread
|
||||
fun getSubscriptionRedemptionJobStatus(): DonationRedemptionJobStatus {
|
||||
return getDonationRedemptionJobStatus(RedemptionType.SUBSCRIPTION)
|
||||
}
|
||||
|
||||
private fun watch(redemptionType: RedemptionType): Observable<Optional<JobTracker.JobState>> = Observable.interval(0, 5, TimeUnit.SECONDS).map {
|
||||
fun watchOneTimeRedemption(): Observable<DonationRedemptionJobStatus> = watch(RedemptionType.ONE_TIME)
|
||||
|
||||
private fun watch(redemptionType: RedemptionType): Observable<DonationRedemptionJobStatus> {
|
||||
return Observable
|
||||
.interval(0, 5, TimeUnit.SECONDS)
|
||||
.map {
|
||||
getDonationRedemptionJobStatus(redemptionType)
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun getDonationRedemptionJobStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
|
||||
val queue = when (redemptionType) {
|
||||
RedemptionType.SUBSCRIPTION -> DonationReceiptRedemptionJob.SUBSCRIPTION_QUEUE
|
||||
RedemptionType.ONE_TIME -> DonationReceiptRedemptionJob.ONE_TIME_QUEUE
|
||||
}
|
||||
|
||||
val externalLaunchJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
|
||||
it.factoryKey == ExternalLaunchDonationJob.KEY && it.parameters.queue?.startsWith(queue) == true
|
||||
}
|
||||
val donationJobSpecs = ApplicationDependencies
|
||||
.getJobManager()
|
||||
.find { it.queueKey?.startsWith(queue) == true }
|
||||
.sortedBy { it.createTime }
|
||||
|
||||
val redemptionJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
|
||||
it.factoryKey == DonationReceiptRedemptionJob.KEY && it.parameters.queue?.startsWith(queue) == true
|
||||
val externalLaunchJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
|
||||
it.factoryKey == ExternalLaunchDonationJob.KEY
|
||||
}
|
||||
|
||||
val receiptRequestJobKey = when (redemptionType) {
|
||||
@@ -44,16 +63,70 @@ object DonationRedemptionJobWatcher {
|
||||
RedemptionType.ONE_TIME -> BoostReceiptRequestResponseJob.KEY
|
||||
}
|
||||
|
||||
val receiptJobState: JobTracker.JobState? = ApplicationDependencies.getJobManager().getFirstMatchingJobState {
|
||||
it.factoryKey == receiptRequestJobKey && it.parameters.queue?.startsWith(queue) == true
|
||||
val receiptJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
|
||||
it.factoryKey == receiptRequestJobKey
|
||||
}
|
||||
|
||||
val jobState: JobTracker.JobState? = externalLaunchJobState ?: redemptionJobState ?: receiptJobState
|
||||
val redemptionJobSpec: JobSpec? = donationJobSpecs.firstOrNull {
|
||||
it.factoryKey == DonationReceiptRedemptionJob.KEY
|
||||
}
|
||||
|
||||
if (redemptionType == RedemptionType.SUBSCRIPTION && jobState == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
|
||||
Optional.of(JobTracker.JobState.FAILURE)
|
||||
val jobSpec: JobSpec? = externalLaunchJobSpec ?: redemptionJobSpec ?: receiptJobSpec
|
||||
|
||||
return if (redemptionType == RedemptionType.SUBSCRIPTION && jobSpec == null && SignalStore.donationsValues().getSubscriptionRedemptionFailed()) {
|
||||
DonationRedemptionJobStatus.FailedSubscription
|
||||
} else {
|
||||
Optional.ofNullable(jobState)
|
||||
jobSpec?.toDonationRedemptionStatus(redemptionType) ?: DonationRedemptionJobStatus.None
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun JobSpec.toDonationRedemptionStatus(redemptionType: RedemptionType): DonationRedemptionJobStatus {
|
||||
return when (factoryKey) {
|
||||
ExternalLaunchDonationJob.KEY -> {
|
||||
val stripe3DSData = ExternalLaunchDonationJob.Factory.parseSerializedData(serializedData!!)
|
||||
DonationRedemptionJobStatus.PendingExternalVerification(
|
||||
pendingOneTimeDonation = pendingOneTimeDonation(redemptionType, stripe3DSData),
|
||||
nonVerifiedMonthlyDonation = nonVerifiedMonthlyDonation(redemptionType, stripe3DSData)
|
||||
)
|
||||
}
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.KEY,
|
||||
BoostReceiptRequestResponseJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRequest
|
||||
|
||||
DonationReceiptRedemptionJob.KEY -> DonationRedemptionJobStatus.PendingReceiptRedemption
|
||||
|
||||
else -> {
|
||||
DonationRedemptionJobStatus.None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun JobSpec.pendingOneTimeDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): PendingOneTimeDonation? {
|
||||
if (redemptionType != RedemptionType.ONE_TIME) {
|
||||
return null
|
||||
}
|
||||
|
||||
return DonationSerializationHelper.createPendingOneTimeDonationProto(
|
||||
badge = stripe3DSData.gatewayRequest.badge,
|
||||
paymentSourceType = stripe3DSData.paymentSourceType,
|
||||
amount = stripe3DSData.gatewayRequest.fiat
|
||||
).copy(
|
||||
timestamp = createTime,
|
||||
pendingVerification = true,
|
||||
checkedVerification = runAttempt > 0
|
||||
)
|
||||
}
|
||||
|
||||
private fun JobSpec.nonVerifiedMonthlyDonation(redemptionType: RedemptionType, stripe3DSData: Stripe3DSData): NonVerifiedMonthlyDonation? {
|
||||
if (redemptionType != RedemptionType.SUBSCRIPTION) {
|
||||
return null
|
||||
}
|
||||
|
||||
return NonVerifiedMonthlyDonation(
|
||||
timestamp = createTime,
|
||||
price = stripe3DSData.gatewayRequest.fiat,
|
||||
level = stripe3DSData.gatewayRequest.level.toInt(),
|
||||
checkedVerification = runAttempt > 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationSerializationHelper.toFiatMoney
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.TerminalDonationDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
@@ -27,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.models.Ne
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DonationErrorValue
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingOneTimeDonation
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -51,6 +53,11 @@ class ManageDonationsFragment :
|
||||
),
|
||||
ExpiredGiftSheet.Callback {
|
||||
|
||||
companion object {
|
||||
private val alertedIdealDonations = mutableSetOf<Long>()
|
||||
const val DONATE_TROUBLESHOOTING_URL = "https://support.signal.org/hc/articles/360031949872#fix"
|
||||
}
|
||||
|
||||
private val supportTechSummary: CharSequence by lazy {
|
||||
SpannableStringBuilder(SpanUtil.color(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant), requireContext().getString(R.string.DonateToSignalFragment__private_messaging)))
|
||||
.append(" ")
|
||||
@@ -92,6 +99,33 @@ class ManageDonationsFragment :
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
|
||||
if (state.nonVerifiedMonthlyDonation?.checkedVerification == true &&
|
||||
!alertedIdealDonations.contains(state.nonVerifiedMonthlyDonation.timestamp)
|
||||
) {
|
||||
alertedIdealDonations += state.nonVerifiedMonthlyDonation.timestamp
|
||||
|
||||
val amount = FiatMoneyUtil.format(resources, state.nonVerifiedMonthlyDonation.price)
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
|
||||
.setMessage(getString(R.string.ManageDonationsFragment__your_monthly_s_donation_couldnt_be_confirmed, amount))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
} else if (state.pendingOneTimeDonation?.pendingVerification == true &&
|
||||
state.pendingOneTimeDonation.checkedVerification &&
|
||||
!alertedIdealDonations.contains(state.pendingOneTimeDonation.timestamp)
|
||||
) {
|
||||
alertedIdealDonations += state.pendingOneTimeDonation.timestamp
|
||||
|
||||
val amount = FiatMoneyUtil.format(resources, state.pendingOneTimeDonation.amount!!.toFiatMoney(), FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.ManageDonationsFragment__couldnt_confirm_donation)
|
||||
.setMessage(getString(R.string.ManageDonationsFragment__your_one_time_s_donation_couldnt_be_confirmed, amount))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +183,14 @@ class ManageDonationsFragment :
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else if (state.hasOneTimeBadge) {
|
||||
} else if (state.nonVerifiedMonthlyDonation != null) {
|
||||
val subscription: Subscription? = state.availableSubscriptions.firstOrNull { it.level == state.nonVerifiedMonthlyDonation.level }
|
||||
if (subscription != null) {
|
||||
presentNonVerifiedSubscriptionSettings(state.nonVerifiedMonthlyDonation, subscription, state)
|
||||
} else {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
}
|
||||
} else if (state.hasOneTimeBadge || state.pendingOneTimeDonation != null) {
|
||||
presentActiveOneTimeDonorSettings(state)
|
||||
} else {
|
||||
presentNotADonorSettings(state.hasReceipts)
|
||||
@@ -186,7 +227,7 @@ class ManageDonationsFragment :
|
||||
displayPendingDialog(it)
|
||||
},
|
||||
onErrorClick = {
|
||||
displayPendingOneTimeDonationErrorDialog(it)
|
||||
displayPendingOneTimeDonationErrorDialog(it, pendingOneTimeDonation.paymentMethodType == PendingOneTimeDonation.PaymentMethodType.IDEAL)
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -241,6 +282,25 @@ class ManageDonationsFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentNonVerifiedSubscriptionSettings(
|
||||
nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation,
|
||||
subscription: Subscription,
|
||||
state: ManageDonationsState
|
||||
) {
|
||||
presentSubscriptionSettingsWithState(state) {
|
||||
customPref(
|
||||
ActiveSubscriptionPreference.Model(
|
||||
price = nonVerifiedMonthlyDonation.price,
|
||||
subscription = subscription,
|
||||
redemptionState = ManageDonationsState.RedemptionState.IN_PROGRESS,
|
||||
onContactSupport = {},
|
||||
activeSubscription = null,
|
||||
onPendingClick = {}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentSubscriptionSettingsWithState(
|
||||
state: ManageDonationsState,
|
||||
subscriptionBlock: DSLConfiguration.() -> Unit
|
||||
@@ -344,14 +404,14 @@ class ManageDonationsFragment :
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue) {
|
||||
private fun displayPendingOneTimeDonationErrorDialog(error: DonationErrorValue, isIdeal: Boolean) {
|
||||
when (error.type) {
|
||||
DonationErrorValue.Type.REDEMPTION -> {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__couldnt_add_badge)
|
||||
.setMessage(R.string.DonationsErrors__your_badge_could_not)
|
||||
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
|
||||
CommunicationActions.openBrowserLink(requireContext(), DONATE_TROUBLESHOOTING_URL)
|
||||
}
|
||||
.setPositiveButton(R.string.Subscription__contact_support) { _, _ ->
|
||||
requireActivity().finish()
|
||||
@@ -363,11 +423,17 @@ class ManageDonationsFragment :
|
||||
.show()
|
||||
}
|
||||
else -> {
|
||||
val message = if (isIdeal) {
|
||||
R.string.DonationsErrors__your_ideal_couldnt_be_processed
|
||||
} else {
|
||||
R.string.DonationsErrors__try_another_payment_method
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.DonationsErrors__error_processing_payment)
|
||||
.setMessage(R.string.DonationsErrors__try_another_payment_method)
|
||||
.setMessage(message)
|
||||
.setNegativeButton(R.string.DonationsErrors__learn_more) { _, _ ->
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
|
||||
CommunicationActions.openBrowserLink(requireContext(), DONATE_TROUBLESHOOTING_URL)
|
||||
}
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setOnDismissListener {
|
||||
|
||||
@@ -12,6 +12,7 @@ data class ManageDonationsState(
|
||||
val subscriptionTransactionState: TransactionState = TransactionState.Init,
|
||||
val availableSubscriptions: List<Subscription> = emptyList(),
|
||||
val pendingOneTimeDonation: PendingOneTimeDonation? = null,
|
||||
val nonVerifiedMonthlyDonation: NonVerifiedMonthlyDonation? = null,
|
||||
private val subscriptionRedemptionState: RedemptionState = RedemptionState.NONE
|
||||
) {
|
||||
|
||||
@@ -26,7 +27,7 @@ data class ManageDonationsState(
|
||||
|
||||
private fun getStateFromActiveSubscription(activeSubscription: ActiveSubscription): RedemptionState? {
|
||||
return when {
|
||||
activeSubscription.isFailedPayment -> RedemptionState.FAILED
|
||||
activeSubscription.isFailedPayment && !activeSubscription.isPastDue -> RedemptionState.FAILED
|
||||
activeSubscription.isPendingBankTransfer -> RedemptionState.IS_PENDING_BANK_TRANSFER
|
||||
activeSubscription.isInProgress -> RedemptionState.IN_PROGRESS
|
||||
else -> null
|
||||
|
||||
@@ -14,13 +14,13 @@ import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.subscription.LevelUpdate
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import java.util.Optional
|
||||
|
||||
class ManageDonationsViewModel(
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
@@ -76,16 +76,27 @@ class ManageDonationsViewModel(
|
||||
store.update { it.copy(hasReceipts = hasReceipts) }
|
||||
}
|
||||
|
||||
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { jobStateOptional ->
|
||||
disposables += DonationRedemptionJobWatcher.watchSubscriptionRedemption().subscribeBy { redemptionStatus ->
|
||||
store.update { manageDonationsState ->
|
||||
manageDonationsState.copy(
|
||||
subscriptionRedemptionState = jobStateOptional.map(this::mapJobStateToRedemptionState).orElse(ManageDonationsState.RedemptionState.NONE)
|
||||
nonVerifiedMonthlyDonation = if (redemptionStatus is DonationRedemptionJobStatus.PendingExternalVerification) redemptionStatus.nonVerifiedMonthlyDonation else null,
|
||||
subscriptionRedemptionState = mapStatusToRedemptionState(redemptionStatus)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += SignalStore.donationsValues()
|
||||
.observablePendingOneTimeDonation
|
||||
disposables += Observable.combineLatest(
|
||||
SignalStore.donationsValues().observablePendingOneTimeDonation,
|
||||
DonationRedemptionJobWatcher.watchOneTimeRedemption()
|
||||
) { pendingFromStore, pendingFromJob ->
|
||||
if (pendingFromStore.isPresent) {
|
||||
pendingFromStore
|
||||
} else if (pendingFromJob is DonationRedemptionJobStatus.PendingExternalVerification) {
|
||||
Optional.ofNullable(pendingFromJob.pendingOneTimeDonation)
|
||||
} else {
|
||||
Optional.empty()
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.subscribeBy { pending ->
|
||||
store.update { it.copy(pendingOneTimeDonation = pending.orNull()) }
|
||||
@@ -122,13 +133,14 @@ class ManageDonationsViewModel(
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapJobStateToRedemptionState(jobState: JobTracker.JobState): ManageDonationsState.RedemptionState {
|
||||
return when (jobState) {
|
||||
JobTracker.JobState.PENDING -> ManageDonationsState.RedemptionState.IN_PROGRESS
|
||||
JobTracker.JobState.RUNNING -> ManageDonationsState.RedemptionState.IN_PROGRESS
|
||||
JobTracker.JobState.SUCCESS -> ManageDonationsState.RedemptionState.NONE
|
||||
JobTracker.JobState.FAILURE -> ManageDonationsState.RedemptionState.FAILED
|
||||
JobTracker.JobState.IGNORED -> ManageDonationsState.RedemptionState.NONE
|
||||
private fun mapStatusToRedemptionState(status: DonationRedemptionJobStatus): ManageDonationsState.RedemptionState {
|
||||
return when (status) {
|
||||
DonationRedemptionJobStatus.FailedSubscription -> ManageDonationsState.RedemptionState.FAILED
|
||||
DonationRedemptionJobStatus.None -> ManageDonationsState.RedemptionState.NONE
|
||||
|
||||
is DonationRedemptionJobStatus.PendingExternalVerification,
|
||||
DonationRedemptionJobStatus.PendingReceiptRedemption,
|
||||
DonationRedemptionJobStatus.PendingReceiptRequest -> ManageDonationsState.RedemptionState.IN_PROGRESS
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.manage
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
|
||||
/**
|
||||
* Represents a monthly donation via iDEAL that is still pending user verification in
|
||||
* their 3rd party app.
|
||||
*/
|
||||
data class NonVerifiedMonthlyDonation(
|
||||
val timestamp: Long,
|
||||
val price: FiatMoney,
|
||||
val level: Int,
|
||||
val checkedVerification: Boolean
|
||||
)
|
||||
@@ -32,7 +32,7 @@ class DonationReceiptListFragment : Fragment(R.layout.donation_receipt_list_frag
|
||||
0 -> R.string.DonationReceiptListFragment__all
|
||||
1 -> R.string.DonationReceiptListFragment__recurring
|
||||
2 -> R.string.DonationReceiptListFragment__one_time
|
||||
3 -> R.string.DonationReceiptListFragment__donation_for_a_friend
|
||||
3 -> R.string.DonationReceiptListFragment__for_a_friend
|
||||
else -> error("Unsupported index $position")
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.updates
|
||||
|
||||
import android.os.Build
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ApkUpdateJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Settings around app updates. Only shown for builds that manage their own app updates.
|
||||
*/
|
||||
class AppUpdatesSettingsFragment : DSLSettingsFragment(R.string.preferences_app_updates__title) {
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
adapter.submitList(getConfiguration().toMappingModelList())
|
||||
}
|
||||
|
||||
private fun getConfiguration(): DSLConfiguration {
|
||||
return configure {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
switchPref(
|
||||
title = DSLSettingsText.from("Automatic updates"),
|
||||
summary = DSLSettingsText.from("Automatically download and install app updates"),
|
||||
isChecked = SignalStore.apkUpdate().autoUpdate,
|
||||
onClick = {
|
||||
SignalStore.apkUpdate().autoUpdate = !SignalStore.apkUpdate().autoUpdate
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from("Check for updates"),
|
||||
summary = DSLSettingsText.from("Last checked on: $lastSuccessfulUpdateString"),
|
||||
onClick = {
|
||||
ApplicationDependencies.getJobManager().add(ApkUpdateJob())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val lastSuccessfulUpdateString: String
|
||||
get() {
|
||||
val lastUpdateTime = SignalStore.apkUpdate().lastSuccessfulCheck
|
||||
|
||||
return if (lastUpdateTime > 0) {
|
||||
val dateFormat = SimpleDateFormat("MMMM dd, yyyy 'at' h:mma", Locale.US)
|
||||
dateFormat.format(Date(lastUpdateTime))
|
||||
} else {
|
||||
"Never"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,12 @@ fun QrCode(
|
||||
}
|
||||
}
|
||||
|
||||
private fun DrawScope.drawQr(
|
||||
fun DrawScope.drawQr(
|
||||
data: QrCodeData,
|
||||
foregroundColor: Color,
|
||||
backgroundColor: Color,
|
||||
deadzonePercent: Float,
|
||||
logo: ImageBitmap
|
||||
logo: ImageBitmap?
|
||||
) {
|
||||
val deadzonePaddingPercent = 0.045f
|
||||
|
||||
@@ -120,12 +120,14 @@ private fun DrawScope.drawQr(
|
||||
// Logo
|
||||
val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt()
|
||||
val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt()
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
)
|
||||
if (logo != null) {
|
||||
drawImage(
|
||||
image = logo,
|
||||
dstOffset = IntOffset(logoOffsetPx, logoOffsetPx),
|
||||
dstSize = IntSize(logoWidthPx, logoWidthPx),
|
||||
colorFilter = ColorFilter.tint(foregroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -24,17 +24,12 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -45,8 +40,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.compose.getScreenshotBounds
|
||||
|
||||
/**
|
||||
* Renders a QR code and username as a badge.
|
||||
@@ -57,23 +50,16 @@ fun QrCodeBadge(
|
||||
colorScheme: UsernameQrCodeColorScheme,
|
||||
username: String,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null,
|
||||
usernameCopyable: Boolean = false,
|
||||
onClick: (() -> Unit) = {}
|
||||
onClick: ((String) -> Unit) = {}
|
||||
) {
|
||||
val borderColor by animateColorAsState(targetValue = colorScheme.borderColor, label = "border")
|
||||
val foregroundColor by animateColorAsState(targetValue = colorScheme.foregroundColor, label = "foreground")
|
||||
val elevation by animateFloatAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) 10f else 0f, label = "elevation")
|
||||
val textColor by animateColorAsState(targetValue = if (colorScheme == UsernameQrCodeColorScheme.White) Color.Black else Color.White, label = "textColor")
|
||||
var badgeBounds by remember {
|
||||
mutableStateOf<Rect?>(null)
|
||||
}
|
||||
screenshotController?.bind(LocalView.current, badgeBounds)
|
||||
val textColor by animateColorAsState(targetValue = colorScheme.textColor, label = "textColor")
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.onGloballyPositioned {
|
||||
badgeBounds = it.getScreenshotBounds()
|
||||
},
|
||||
modifier = modifier,
|
||||
color = borderColor,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
shadowElevation = elevation.dp
|
||||
@@ -99,8 +85,8 @@ fun QrCodeBadge(
|
||||
data = data.data,
|
||||
modifier = Modifier
|
||||
.border(
|
||||
width = if (colorScheme == UsernameQrCodeColorScheme.White) 2.dp else 0.dp,
|
||||
color = Color(0xFFE9E9E9),
|
||||
width = 2.dp,
|
||||
color = colorScheme.outlineColor,
|
||||
shape = RoundedCornerShape(size = 12.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
@@ -146,7 +132,7 @@ fun QrCodeBadge(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
enabled = usernameCopyable,
|
||||
onClick = onClick
|
||||
onClick = { onClick(username) }
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.compose.ui.graphics.Color
|
||||
enum class UsernameQrCodeColorScheme(
|
||||
val borderColor: Color,
|
||||
val foregroundColor: Color,
|
||||
val textColor: Color = Color.White,
|
||||
val outlineColor: Color = Color.Transparent,
|
||||
private val key: String
|
||||
) {
|
||||
Blue(
|
||||
@@ -18,6 +20,8 @@ enum class UsernameQrCodeColorScheme(
|
||||
White(
|
||||
borderColor = Color(0xFFFFFFFF),
|
||||
foregroundColor = Color(0xFF000000),
|
||||
textColor = Color.Black,
|
||||
outlineColor = Color(0xFFE9E9E9),
|
||||
key = "white"
|
||||
),
|
||||
Grey(
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.settings.app.usernamelinks.colorpi
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -15,6 +14,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
@@ -104,7 +104,11 @@ class UsernameLinkQrColorPickerFragment : ComposeFragment() {
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Image(painter = painterResource(R.drawable.symbol_arrow_left_24), contentDescription = null)
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -15,9 +15,9 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeSt
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
|
||||
class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
|
||||
@@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() {
|
||||
|
||||
if (usernameLink != null) {
|
||||
disposable += Single
|
||||
.fromCallable { QrCodeData.forData(UsernameUtil.generateLink(usernameLink), 64) }
|
||||
.fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.Intent
|
||||
@@ -9,22 +11,26 @@ import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -32,15 +38,19 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.PermissionState
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -51,20 +61,26 @@ import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.UUID
|
||||
|
||||
@OptIn(
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
|
||||
private val viewModel: UsernameLinkSettingsViewModel by viewModels()
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val screenshotController = ScreenshotController()
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
setFragmentResultListener(UsernameLinkShareBottomSheet.REQUEST_KEY) { key, bundle ->
|
||||
if (bundle.getBoolean(UsernameLinkShareBottomSheet.KEY_COPY)) {
|
||||
viewModel.onLinkCopied()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
@@ -72,10 +88,30 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
val scope: CoroutineScope = rememberCoroutineScope()
|
||||
val navController: NavController by remember { mutableStateOf(findNavController()) }
|
||||
var showResetDialog: Boolean by remember { mutableStateOf(false) }
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
|
||||
val linkCopiedEvent: UUID? by viewModel.linkCopiedEvent
|
||||
|
||||
val linkCopiedString = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
|
||||
|
||||
LaunchedEffect(linkCopiedEvent) {
|
||||
if (linkCopiedEvent != null) {
|
||||
snackbarHostState.showSnackbar(linkCopiedString)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
topBar = { TopAppBarContent(state.activeTab) }
|
||||
topBar = {
|
||||
TopAppBarContent(
|
||||
activeTab = state.activeTab,
|
||||
scrollBehavior = scrollBehavior,
|
||||
onCodeTabSelected = { viewModel.onTabSelected(ActiveTab.Code) },
|
||||
onScanTabSelected = { viewModel.onTabSelected(ActiveTab.Scan) },
|
||||
cameraPermissionState = cameraPermissionState
|
||||
)
|
||||
},
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) { contentPadding ->
|
||||
|
||||
if (state.indeterminateProgress) {
|
||||
@@ -94,9 +130,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
navController = navController,
|
||||
onShareBadge = {
|
||||
shareQrBadge(it)
|
||||
shareQrBadge(viewModel.generateQrCodeImage())
|
||||
},
|
||||
screenshotController = screenshotController,
|
||||
onResetClicked = { showResetDialog = true },
|
||||
onLinkResultHandled = { viewModel.onUsernameLinkResetResultHandled() }
|
||||
)
|
||||
@@ -138,41 +173,57 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
viewModel.onResume()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun TopAppBarContent(activeTab: ActiveTab) {
|
||||
val cameraPermissionState: PermissionState = rememberPermissionState(permission = android.Manifest.permission.CAMERA)
|
||||
|
||||
Box(
|
||||
private fun TopAppBarContent(
|
||||
activeTab: ActiveTab,
|
||||
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(),
|
||||
onCodeTabSelected: () -> Unit = {},
|
||||
onScanTabSelected: () -> Unit = {},
|
||||
cameraPermissionState: PermissionState = previewPermissionState()
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
|
||||
active = activeTab == ActiveTab.Code,
|
||||
onClick = { viewModel.onTabSelected(ActiveTab.Code) },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
|
||||
active = activeTab == ActiveTab.Scan,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
viewModel.onTabSelected(ActiveTab.Scan)
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.fillMaxWidth(),
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_code_tab_name),
|
||||
active = activeTab == ActiveTab.Code,
|
||||
onClick = onCodeTabSelected,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
TabButton(
|
||||
label = stringResource(R.string.UsernameLinkSettings_scan_tab_name),
|
||||
active = activeTab == ActiveTab.Scan,
|
||||
onClick = {
|
||||
if (cameraPermissionState.status.isGranted) {
|
||||
onScanTabSelected()
|
||||
} else {
|
||||
cameraPermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { requireActivity().onNavigateUp() }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_x_24),
|
||||
contentDescription = stringResource(android.R.string.cancel)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -185,7 +236,7 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Buttons.MediumTonal(
|
||||
Buttons.Small(
|
||||
onClick = onClick,
|
||||
modifier = modifier.defaultMinSize(minWidth = 100.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -234,7 +285,19 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareQrBadge(badge: Bitmap) {
|
||||
private fun previewPermissionState(): PermissionState {
|
||||
return object : PermissionState {
|
||||
override val permission: String = ""
|
||||
override val status: PermissionStatus = PermissionStatus.Granted
|
||||
override fun launchPermissionRequest() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareQrBadge(badge: Bitmap?) {
|
||||
if (badge == null) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ByteArrayOutputStream().use { byteArrayOutputStream ->
|
||||
badge.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.PorterDuffColorFilter
|
||||
import android.graphics.Rect
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Typeface
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Canvas
|
||||
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
@@ -10,17 +27,20 @@ import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.drawQr
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import org.whispersystems.signalservice.api.push.UsernameLinkComponents
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
@@ -30,7 +50,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
UsernameLinkSettingsState(
|
||||
activeTab = ActiveTab.Code,
|
||||
username = SignalStore.account().username!!,
|
||||
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(UsernameUtil.generateLink(it)) } ?: UsernameLinkState.NotSet,
|
||||
usernameLinkState = SignalStore.account().usernameLink?.let { UsernameLinkState.Present(it.toLink()) } ?: UsernameLinkState.NotSet,
|
||||
qrCodeState = QrCodeState.Loading,
|
||||
qrCodeColorScheme = SignalStore.misc().usernameQrCodeColorScheme
|
||||
)
|
||||
@@ -39,12 +59,14 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
private val disposable: CompositeDisposable = CompositeDisposable()
|
||||
private val usernameLink: BehaviorSubject<Optional<UsernameLinkComponents>> = BehaviorSubject.createDefault(Optional.ofNullable(SignalStore.account().usernameLink))
|
||||
private val usernameRepo: UsernameRepository = UsernameRepository()
|
||||
|
||||
private val _linkCopiedEvent: MutableState<UUID?> = mutableStateOf(null)
|
||||
val linkCopiedEvent: State<UUID?> get() = _linkCopiedEvent
|
||||
|
||||
init {
|
||||
disposable += usernameLink
|
||||
.observeOn(Schedulers.io())
|
||||
.map { link -> link.map { UsernameUtil.generateLink(it) } }
|
||||
.map { link -> link.map { it.toLink() } }
|
||||
.flatMapSingle { generateQrCodeData(it) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { qrData ->
|
||||
@@ -52,6 +74,10 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
qrCodeState = if (qrData.isPresent) QrCodeState.Present(qrData.get()) else QrCodeState.NotSet
|
||||
)
|
||||
}
|
||||
|
||||
if (SignalStore.account().usernameLink == null) {
|
||||
onUsernameLinkReset()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
@@ -90,7 +116,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
qrCodeState = QrCodeState.Loading
|
||||
)
|
||||
|
||||
disposable += usernameRepo.createOrResetUsernameLink()
|
||||
disposable += UsernameRepository.createOrResetUsernameLink()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { result ->
|
||||
val components: Optional<UsernameLinkComponents> = when (result) {
|
||||
@@ -101,18 +127,22 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
usernameLinkState = if (components.isPresent) {
|
||||
val link = UsernameUtil.generateLink(components.get())
|
||||
val link = components.get().toLink()
|
||||
UsernameLinkState.Present(link)
|
||||
} else {
|
||||
UsernameLinkState.NotSet
|
||||
},
|
||||
usernameLinkResetResult = result,
|
||||
qrCodeState = if (components.isPresent && previousQrValue != null) {
|
||||
qrCodeState = if (!components.isPresent && previousQrValue != null) {
|
||||
QrCodeState.Present(previousQrValue)
|
||||
} else {
|
||||
QrCodeState.NotSet
|
||||
}
|
||||
)
|
||||
|
||||
if (components.isPresent) {
|
||||
usernameLink.onNext(components)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +157,7 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
indeterminateProgress = true
|
||||
)
|
||||
|
||||
disposable += usernameRepo.convertLinkToUsernameAndAci(url)
|
||||
disposable += UsernameRepository.fetchUsernameAndAciFromLink(url)
|
||||
.map { result ->
|
||||
when (result) {
|
||||
is UsernameRepository.UsernameLinkConversionResult.Success -> QrScanResult.Success(Recipient.externalUsername(result.aci, result.username.toString()))
|
||||
@@ -152,9 +182,115 @@ class UsernameLinkSettingsViewModel : ViewModel() {
|
||||
)
|
||||
}
|
||||
|
||||
fun onLinkCopied() {
|
||||
_linkCopiedEvent.value = UUID.randomUUID()
|
||||
}
|
||||
|
||||
private fun generateQrCodeData(url: Optional<String>): Single<Optional<QrCodeData>> {
|
||||
return Single.fromCallable {
|
||||
url.map { QrCodeData.forData(it, 64) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fun fact: there's no way to draw a composable to a bitmap. You'd think there would be, but there isn't. You can "screenshot" it if it's 100% on-screen,
|
||||
* but if it's partially offscreen you're SOL. So, we get to go through the fun process of re-drawing the QR badge to an image for sharing ourselves.
|
||||
*
|
||||
* Sizes were picked arbitrarily.
|
||||
*
|
||||
* I hate this as much as you do.
|
||||
*/
|
||||
fun generateQrCodeImage(): Bitmap? {
|
||||
val state: UsernameLinkSettingsState = _state.value
|
||||
|
||||
if (state.qrCodeState !is QrCodeState.Present) {
|
||||
Log.w(TAG, "Invalid state to generate QR code! ${state.qrCodeState.javaClass.simpleName}")
|
||||
return null
|
||||
}
|
||||
|
||||
val qrCodeData: QrCodeData = state.qrCodeState.data
|
||||
|
||||
val width = 480
|
||||
val height = 525
|
||||
val qrSize = 300f
|
||||
val qrPadding = 25f
|
||||
val borderSizeX = 64f
|
||||
val borderSizeY = 52f
|
||||
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply {
|
||||
eraseColor(Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
val androidCanvas = android.graphics.Canvas(bitmap)
|
||||
val composeCanvas = Canvas(androidCanvas)
|
||||
val canvasDrawScope = CanvasDrawScope()
|
||||
|
||||
// Draw the background
|
||||
androidCanvas.drawRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 30f, 30f, Paint().apply { color = state.qrCodeColorScheme.borderColor.toArgb() })
|
||||
androidCanvas.drawRoundRect(borderSizeX, borderSizeY, borderSizeX + qrSize + qrPadding * 2, borderSizeY + qrSize + qrPadding * 2, 15f, 15f, Paint().apply { color = Color.WHITE })
|
||||
androidCanvas.drawRoundRect(
|
||||
borderSizeX,
|
||||
borderSizeY,
|
||||
borderSizeX + qrSize + qrPadding * 2,
|
||||
borderSizeY + qrSize + qrPadding * 2,
|
||||
15f,
|
||||
15f,
|
||||
Paint().apply {
|
||||
color = state.qrCodeColorScheme.outlineColor.toArgb()
|
||||
style = Paint.Style.STROKE
|
||||
strokeWidth = 4f
|
||||
}
|
||||
)
|
||||
|
||||
// Draw the QR code
|
||||
composeCanvas.translate((width / 2) - (qrSize / 2), 80f)
|
||||
canvasDrawScope.draw(
|
||||
density = object : Density {
|
||||
override val density: Float = 1f
|
||||
override val fontScale: Float = 1f
|
||||
},
|
||||
layoutDirection = LayoutDirection.Ltr,
|
||||
canvas = composeCanvas,
|
||||
size = Size(qrSize, qrSize)
|
||||
) {
|
||||
drawQr(
|
||||
data = qrCodeData,
|
||||
foregroundColor = state.qrCodeColorScheme.foregroundColor,
|
||||
backgroundColor = state.qrCodeColorScheme.borderColor,
|
||||
deadzonePercent = 0.35f,
|
||||
logo = null
|
||||
)
|
||||
}
|
||||
composeCanvas.translate(-90f, -80f)
|
||||
|
||||
// Draw the signal logo -- unfortunately can't have the normal QR code drawing handle it because it requires a composable ImageBitmap
|
||||
BitmapFactory.decodeResource(ApplicationDependencies.getApplication().resources, R.drawable.qrcode_logo).also { logoBitmap ->
|
||||
val tintedPaint = Paint().apply {
|
||||
colorFilter = PorterDuffColorFilter(state.qrCodeColorScheme.foregroundColor.toArgb(), PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
val sourceRect = Rect(0, 0, logoBitmap.width, logoBitmap.height)
|
||||
val destRect = RectF(210f, 200f, 270f, 260f)
|
||||
androidCanvas.drawBitmap(logoBitmap, sourceRect, destRect, tintedPaint)
|
||||
}
|
||||
|
||||
// Draw the text
|
||||
val textPaint = Paint().apply {
|
||||
color = state.qrCodeColorScheme.textColor.toArgb()
|
||||
textSize = 34f
|
||||
typeface = if (Build.VERSION.SDK_INT < 26) {
|
||||
Typeface.DEFAULT_BOLD
|
||||
} else {
|
||||
Typeface.Builder("")
|
||||
.setFallback("sans-serif")
|
||||
.setWeight(600)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
val textBounds = Rect()
|
||||
textPaint.getTextBounds(state.username, 0, state.username.length, textBounds)
|
||||
|
||||
androidCanvas.drawText(state.username, (width / 2f) - (textBounds.width() / 2f), 465f, textPaint)
|
||||
|
||||
return bitmap
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.toLink
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
|
||||
class UsernameLinkShareBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "link_share_bottom_sheet"
|
||||
const val KEY_COPY = "copy"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
CallLinkIncomingRequestSheet().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
Content(
|
||||
usernameLink = SignalStore.account().usernameLink?.toLink() ?: "",
|
||||
dismissDialog = { didCopy ->
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(KEY_COPY to didCopy))
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
usernameLink: String,
|
||||
dismissDialog: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.UsernameLinkShareBottomSheet_title),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 41.dp, vertical = 24.dp)
|
||||
)
|
||||
Text(
|
||||
text = usernameLink,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(all = 16.dp)
|
||||
)
|
||||
ButtonRow(
|
||||
icon = painterResource(R.drawable.symbol_copy_android_24),
|
||||
text = stringResource(R.string.UsernameLinkShareBottomSheet_copy_link),
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
onClick = {
|
||||
Util.copyToClipboard(context, usernameLink)
|
||||
dismissDialog(true)
|
||||
}
|
||||
)
|
||||
ButtonRow(
|
||||
icon = painterResource(R.drawable.symbol_share_android_24),
|
||||
text = stringResource(R.string.UsernameLinkShareBottomSheet_share),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
onClick = {
|
||||
dismissDialog(false)
|
||||
|
||||
val sendIntent: Intent = Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, usernameLink)
|
||||
}
|
||||
|
||||
context.startActivity(Intent.createChooser(sendIntent, null))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonRow(icon: Painter, text: String, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = text,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
)
|
||||
Text(
|
||||
text = text,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "content", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ContentPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
Content(
|
||||
usernameLink = "https://signal.me#eufzLWmFFUYAOqnVJ4Zlt0KqXf87r59FC1hZ3r7WipjKvgzMBg7DBlY5DB5hQTjsw0"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Light Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "button row", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun ButtonRowPreview() {
|
||||
SignalTheme {
|
||||
Surface {
|
||||
ButtonRow(icon = painterResource(R.drawable.symbol_share_android_24), text = "Share")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.usernamelinks.main
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -43,7 +42,6 @@ import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeDa
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeState
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme
|
||||
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.UsernameLinkSettingsState.ActiveTab
|
||||
import org.thoughtcrime.securesms.compose.ScreenshotController
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -57,11 +55,12 @@ fun UsernameLinkShareScreen(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope,
|
||||
navController: NavController,
|
||||
onShareBadge: (Bitmap) -> Unit,
|
||||
onShareBadge: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
screenshotController: ScreenshotController? = null,
|
||||
onResetClicked: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
when (state.usernameLinkResetResult) {
|
||||
UsernameLinkResetResult.NetworkUnavailable -> {
|
||||
ResetLinkResultDialog(stringResource(R.string.UsernameLinkSettings_reset_link_result_network_unavailable), onDismiss = onLinkResultHandled)
|
||||
@@ -82,10 +81,10 @@ fun UsernameLinkShareScreen(
|
||||
data = state.qrCodeState,
|
||||
colorScheme = state.qrCodeColorScheme,
|
||||
username = state.username,
|
||||
screenshotController = screenshotController,
|
||||
usernameCopyable = true,
|
||||
modifier = Modifier.padding(horizontal = 58.dp, vertical = 24.dp),
|
||||
onClick = {
|
||||
onClick = { username ->
|
||||
Util.copyToClipboard(context, username)
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(usernameCopiedString)
|
||||
}
|
||||
@@ -93,19 +92,15 @@ fun UsernameLinkShareScreen(
|
||||
)
|
||||
|
||||
ButtonBar(
|
||||
onShareClicked = {
|
||||
val badgeBitmap = screenshotController?.screenshot()
|
||||
if (badgeBitmap != null) {
|
||||
onShareBadge.invoke(badgeBitmap)
|
||||
}
|
||||
},
|
||||
onColorClicked = { navController.safeNavigate(R.id.action_usernameLinkSettingsFragment_to_usernameLinkQrColorPickerFragment) }
|
||||
onShareClicked = onShareBadge,
|
||||
onColorClicked = { navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkQrColorPickerFragment()) }
|
||||
)
|
||||
|
||||
LinkRow(
|
||||
linkState = state.usernameLinkState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
onClick = {
|
||||
navController.safeNavigate(UsernameLinkSettingsFragmentDirections.actionUsernameLinkSettingsFragmentToUsernameLinkShareBottomSheet())
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -151,9 +146,7 @@ private fun ButtonBar(onShareClicked: () -> Unit, onColorClicked: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
|
||||
val context = LocalContext.current
|
||||
val copyMessage = stringResource(R.string.UsernameLinkSettings_link_copied_toast)
|
||||
private fun LinkRow(linkState: UsernameLinkState, onClick: () -> Unit = {}) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -170,11 +163,7 @@ private fun LinkRow(linkState: UsernameLinkState, snackbarHostState: SnackbarHos
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable(enabled = linkState is UsernameLinkState.Present) {
|
||||
Util.copyToClipboard(context, (linkState as UsernameLinkState.Present).link)
|
||||
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(copyMessage)
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
.padding(horizontal = 26.dp, vertical = 16.dp)
|
||||
.alpha(if (linkState is UsernameLinkState.Present) 1.0f else 0.6f)
|
||||
@@ -234,19 +223,13 @@ private fun LinkRowPreview() {
|
||||
Surface {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
linkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf")
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.NotSet,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
linkState = UsernameLinkState.NotSet
|
||||
)
|
||||
LinkRow(
|
||||
linkState = UsernameLinkState.Resetting,
|
||||
snackbarHostState = SnackbarHostState(),
|
||||
scope = rememberCoroutineScope()
|
||||
linkState = UsernameLinkState.Resetting
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,4 @@ sealed class ConversationSettingsEvent {
|
||||
class ShowMembersAdded(
|
||||
val membersAddedCount: Int
|
||||
) : ConversationSettingsEvent()
|
||||
|
||||
class InitiateGroupMigration(
|
||||
val recipientId: RecipientId
|
||||
) : ConversationSettingsEvent()
|
||||
}
|
||||
|
||||
@@ -72,10 +72,9 @@ import org.thoughtcrime.securesms.groups.ui.invitesandrequests.ManagePendingAndR
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupDescriptionDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupInviteSentDialog
|
||||
import org.thoughtcrime.securesms.groups.ui.managegroup.dialogs.GroupsLearnMoreBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.groups.ui.migration.GroupsV1MigrationInitiationBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity
|
||||
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientExporter
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -199,7 +198,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
val args = ConversationSettingsFragmentArgs.fromBundle(requireArguments())
|
||||
val groupId = args.groupId as ParcelableGroupId
|
||||
|
||||
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), requireNotNull(ParcelableGroupId.get(groupId))))
|
||||
true
|
||||
} else {
|
||||
super.onOptionsItemSelected(item)
|
||||
@@ -279,7 +278,6 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
is ConversationSettingsEvent.ShowAddMembersToGroupError -> showAddMembersToGroupError(event)
|
||||
is ConversationSettingsEvent.ShowGroupInvitesSentDialog -> showGroupInvitesSentDialog(event)
|
||||
is ConversationSettingsEvent.ShowMembersAdded -> showMembersAdded(event)
|
||||
is ConversationSettingsEvent.InitiateGroupMigration -> GroupsV1MigrationInitiationBottomSheetDialogFragment.showForInitiation(parentFragmentManager, event.recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,7 +349,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
descriptionShouldLinkify = groupState.groupDescriptionShouldLinkify,
|
||||
canEditGroupAttributes = groupState.canEditGroupAttributes,
|
||||
onEditGroupDescription = {
|
||||
startActivity(EditProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
|
||||
startActivity(CreateProfileActivity.getIntentForGroupProfile(requireActivity(), groupState.groupId))
|
||||
},
|
||||
onViewGroupDescription = {
|
||||
GroupDescriptionDialog.show(childFragmentManager, groupState.groupId, null, groupState.groupDescriptionShouldLinkify)
|
||||
@@ -363,7 +361,6 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
LegacyGroupPreference.Model(
|
||||
state = groupState.legacyGroupState,
|
||||
onLearnMoreClick = { GroupsLearnMoreBottomSheetDialogFragment.show(parentFragmentManager) },
|
||||
onUpgradeClick = { viewModel.initiateGroupUpgrade() },
|
||||
onMmsWarningClick = { startActivity(Intent(requireContext(), InviteActivity::class.java)) }
|
||||
)
|
||||
)
|
||||
@@ -555,7 +552,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
}
|
||||
|
||||
if (state.sharedMedia != null && state.sharedMedia.count > 0) {
|
||||
if (state.sharedMedia.isNotEmpty()) {
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.recipient_preference_activity__shared_media)
|
||||
@@ -563,7 +560,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
@Suppress("DEPRECATION")
|
||||
customPref(
|
||||
SharedMediaPreference.Model(
|
||||
mediaCursor = state.sharedMedia,
|
||||
mediaRecords = state.sharedMedia,
|
||||
mediaIds = state.sharedMediaIds,
|
||||
onMediaRecordClick = { view, mediaRecord, isLtr ->
|
||||
view.transitionName = "thumb"
|
||||
|
||||
@@ -30,7 +30,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import java.io.IOException
|
||||
import java.util.Optional
|
||||
|
||||
private val TAG = Log.tag(ConversationSettingsRepository::class.java)
|
||||
|
||||
@@ -56,11 +55,11 @@ class ConversationSettingsRepository(
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
fun getThreadMedia(threadId: Long): Optional<Cursor> {
|
||||
return if (threadId <= 0) {
|
||||
Optional.empty()
|
||||
fun getThreadMedia(threadId: Long, limit: Int): Cursor? {
|
||||
return if (threadId > 0) {
|
||||
SignalDatabase.media.getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest, limit)
|
||||
} else {
|
||||
Optional.of(SignalDatabase.media.getGalleryMediaForThread(threadId, MediaTable.Sorting.Newest))
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation
|
||||
|
||||
import android.database.Cursor
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
@@ -18,7 +18,7 @@ data class ConversationSettingsState(
|
||||
val buttonStripState: ButtonStripPreference.State = ButtonStripPreference.State(),
|
||||
val disappearingMessagesLifespan: Int = 0,
|
||||
val canModifyBlockedState: Boolean = false,
|
||||
val sharedMedia: Cursor? = null,
|
||||
val sharedMedia: List<MediaTable.MediaRecord> = emptyList(),
|
||||
val sharedMediaIds: List<Long> = listOf(),
|
||||
val displayInternalRecipientDetails: Boolean = false,
|
||||
val calls: List<CallPreference.Model> = emptyList(),
|
||||
|
||||
@@ -13,13 +13,13 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.readToList
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.ButtonStripPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.CallPreference
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LegacyGroupPreference
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.MediaTable
|
||||
import org.thoughtcrime.securesms.database.RecipientTable
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
@@ -30,11 +30,9 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Optional
|
||||
|
||||
sealed class ConversationSettingsViewModel(
|
||||
private val callMessageIds: LongArray,
|
||||
@@ -42,8 +40,6 @@ sealed class ConversationSettingsViewModel(
|
||||
specificSettingsState: SpecificSettingsState
|
||||
) : ViewModel() {
|
||||
|
||||
private val openedMediaCursors = HashSet<Cursor>()
|
||||
|
||||
@Volatile
|
||||
private var cleared = false
|
||||
|
||||
@@ -66,37 +62,26 @@ sealed class ConversationSettingsViewModel(
|
||||
val threadId: LiveData<Long> = state.map { it.threadId }.distinctUntilChanged()
|
||||
val updater: LiveData<Long> = LiveDataUtil.combineLatest(threadId, sharedMediaUpdateTrigger) { tId, _ -> tId }
|
||||
|
||||
val sharedMedia: LiveData<Optional<Cursor>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
|
||||
repository.getThreadMedia(tId)
|
||||
val sharedMedia: LiveData<List<MediaTable.MediaRecord>> = LiveDataUtil.mapAsync(SignalExecutors.BOUNDED, updater) { tId ->
|
||||
repository.getThreadMedia(threadId = tId, limit = 100)?.readToList { cursor ->
|
||||
MediaTable.MediaRecord.from(cursor)
|
||||
} ?: emptyList()
|
||||
}
|
||||
|
||||
store.update(repository.getCallEvents(callMessageIds).toObservable()) { callRecords, state ->
|
||||
state.copy(calls = callRecords.map { (call, messageRecord) -> CallPreference.Model(call, messageRecord) })
|
||||
}
|
||||
|
||||
store.update(sharedMedia) { cursor, state ->
|
||||
store.update(sharedMedia) { mediaRecords, state ->
|
||||
if (!cleared) {
|
||||
if (cursor.isPresent) {
|
||||
openedMediaCursors.add(cursor.get())
|
||||
}
|
||||
|
||||
val ids: List<Long> = cursor.map<List<Long>> {
|
||||
val result = mutableListOf<Long>()
|
||||
while (it.moveToNext()) {
|
||||
result.add(CursorUtil.requireLong(it, AttachmentTable.ROW_ID))
|
||||
}
|
||||
result
|
||||
}.orElse(listOf())
|
||||
|
||||
state.copy(
|
||||
sharedMedia = cursor.orElse(null),
|
||||
sharedMediaIds = ids,
|
||||
sharedMedia = mediaRecords,
|
||||
sharedMediaIds = mediaRecords.mapNotNull { it.attachment?.attachmentId?.rowId },
|
||||
sharedMediaLoaded = true,
|
||||
displayInternalRecipientDetails = repository.isInternalRecipientDetailsEnabled()
|
||||
)
|
||||
} else {
|
||||
cursor.orElse(null).ensureClosed()
|
||||
state.copy(sharedMedia = null)
|
||||
state.copy(sharedMedia = emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,7 +108,6 @@ sealed class ConversationSettingsViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
cleared = true
|
||||
openedMediaCursors.forEach { it.ensureClosed() }
|
||||
store.clear()
|
||||
disposable.clear()
|
||||
}
|
||||
@@ -134,8 +118,6 @@ sealed class ConversationSettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
open fun initiateGroupUpgrade(): Unit = error("This ViewModel does not support this interaction")
|
||||
|
||||
private class RecipientSettingsViewModel(
|
||||
private val recipientId: RecipientId,
|
||||
private val callMessageIds: LongArray,
|
||||
@@ -296,7 +278,7 @@ sealed class ConversationSettingsViewModel(
|
||||
),
|
||||
canModifyBlockedState = RecipientUtil.isBlockable(recipient),
|
||||
specificSettingsState = state.requireGroupSettingsState().copy(
|
||||
legacyGroupState = getLegacyGroupState(recipient)
|
||||
legacyGroupState = getLegacyGroupState()
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -405,14 +387,8 @@ sealed class ConversationSettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun getLegacyGroupState(recipient: Recipient): LegacyGroupPreference.State {
|
||||
val showLegacyInfo = recipient.requireGroupId().isV1
|
||||
|
||||
return if (showLegacyInfo && recipient.participantIds.size > FeatureFlags.groupLimits().hardLimit) {
|
||||
LegacyGroupPreference.State.TOO_LARGE
|
||||
} else if (showLegacyInfo) {
|
||||
LegacyGroupPreference.State.UPGRADE
|
||||
} else if (groupId.isMms) {
|
||||
private fun getLegacyGroupState(): LegacyGroupPreference.State {
|
||||
return if (groupId.isMms) {
|
||||
LegacyGroupPreference.State.MMS_WARNING
|
||||
} else {
|
||||
LegacyGroupPreference.State.NONE
|
||||
@@ -483,12 +459,6 @@ sealed class ConversationSettingsViewModel(
|
||||
override fun unblock() {
|
||||
repository.unblock(groupId)
|
||||
}
|
||||
|
||||
override fun initiateGroupUpgrade() {
|
||||
repository.getExternalPossiblyMigratedGroupRecipientId(groupId) {
|
||||
internalEvents.onNext(ConversationSettingsEvent.InitiateGroupMigration(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Renders a single call preference row when displaying call info.
|
||||
@@ -82,7 +81,7 @@ object CallPreference {
|
||||
}
|
||||
|
||||
private fun getCallTime(messageRecord: MessageRecord): String {
|
||||
return DateUtils.getOnlyTimeString(context, Locale.getDefault(), messageRecord.timestamp)
|
||||
return DateUtils.getOnlyTimeString(context, messageRecord.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
@@ -19,7 +18,6 @@ object LegacyGroupPreference {
|
||||
class Model(
|
||||
val state: State,
|
||||
val onLearnMoreClick: () -> Unit,
|
||||
val onUpgradeClick: () -> Unit,
|
||||
val onMmsWarningClick: () -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
@@ -42,15 +40,6 @@ object LegacyGroupPreference {
|
||||
groupInfoText.setOnLinkClickListener { model.onLearnMoreClick() }
|
||||
groupInfoText.setLearnMoreVisible(true)
|
||||
}
|
||||
State.UPGRADE -> {
|
||||
groupInfoText.setText(R.string.ManageGroupActivity_legacy_group_upgrade)
|
||||
groupInfoText.setOnLinkClickListener { model.onUpgradeClick() }
|
||||
groupInfoText.setLearnMoreVisible(true, R.string.ManageGroupActivity_upgrade_this_group)
|
||||
}
|
||||
State.TOO_LARGE -> {
|
||||
groupInfoText.text = context.getString(R.string.ManageGroupActivity_legacy_group_too_large, FeatureFlags.groupLimits().hardLimit - 1)
|
||||
groupInfoText.setLearnMoreVisible(false)
|
||||
}
|
||||
State.MMS_WARNING -> {
|
||||
groupInfoText.setText(R.string.ManageGroupActivity_this_is_an_insecure_mms_group)
|
||||
groupInfoText.setOnLinkClickListener { model.onMmsWarningClick() }
|
||||
@@ -63,8 +52,6 @@ object LegacyGroupPreference {
|
||||
|
||||
enum class State {
|
||||
LEARN_MORE,
|
||||
UPGRADE,
|
||||
TOO_LARGE,
|
||||
MMS_WARNING,
|
||||
NONE
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package org.thoughtcrime.securesms.components.settings.conversation.preferences
|
||||
|
||||
import android.database.Cursor
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ThreadPhotoRailView
|
||||
@@ -22,7 +21,7 @@ object SharedMediaPreference {
|
||||
}
|
||||
|
||||
class Model(
|
||||
val mediaCursor: Cursor,
|
||||
val mediaRecords: List<MediaTable.MediaRecord>,
|
||||
val mediaIds: List<Long>,
|
||||
val onMediaRecordClick: (View, MediaTable.MediaRecord, Boolean) -> Unit
|
||||
) : PreferenceModel<Model>() {
|
||||
@@ -41,7 +40,7 @@ object SharedMediaPreference {
|
||||
private val rail: ThreadPhotoRailView = itemView.findViewById(R.id.rail_view)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
rail.setCursor(GlideApp.with(rail), model.mediaCursor)
|
||||
rail.setMediaRecords(GlideApp.with(rail), model.mediaRecords)
|
||||
rail.setListener { v, m ->
|
||||
model.onMediaRecordClick(v, m, ViewUtil.isLtr(rail))
|
||||
}
|
||||
|
||||
@@ -207,7 +207,11 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
|
||||
private fun displayPendingGallery(currentState: TransferControlViewState) {
|
||||
binding.primaryProgressView.startClickListener = currentState.downloadClickedListener
|
||||
applyFocusableAndClickable(currentState, listOf(binding.primaryProgressView, binding.primaryDetailsText), listOf(binding.secondaryProgressView, binding.playVideoButton))
|
||||
applyFocusableAndClickable(
|
||||
currentState,
|
||||
listOf(binding.primaryProgressView, binding.primaryDetailsText, binding.primaryBackground),
|
||||
listOf(binding.secondaryProgressView, binding.playVideoButton)
|
||||
)
|
||||
binding.primaryProgressView.setStopped(false)
|
||||
showAllViews(
|
||||
playVideoButton = false,
|
||||
@@ -215,6 +219,9 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
secondaryDetailsText = currentState.showSecondaryText
|
||||
)
|
||||
|
||||
binding.primaryDetailsText.setOnClickListener(currentState.downloadClickedListener)
|
||||
binding.primaryBackground.setOnClickListener(currentState.downloadClickedListener)
|
||||
|
||||
binding.primaryDetailsText.translationX = if (ViewUtil.isLtr(this)) {
|
||||
ViewUtil.dpToPx(-8).toFloat()
|
||||
} else {
|
||||
@@ -430,7 +437,10 @@ class TransferControlView @JvmOverloads constructor(context: Context, attrs: Att
|
||||
inactiveViews.forEach { it.focusable = View.NOT_FOCUSABLE }
|
||||
}
|
||||
activeViews.forEach { it.isClickable = currentState.isClickable }
|
||||
inactiveViews.forEach { it.isClickable = false }
|
||||
inactiveViews.forEach {
|
||||
it.setOnClickListener(null)
|
||||
it.isClickable = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
|
||||
@@ -100,7 +100,7 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun isDarkTheme(): Boolean = true
|
||||
override val forceDarkTheme = true
|
||||
|
||||
private val webRtcCallViewModel: WebRtcCallViewModel by activityViewModels()
|
||||
private val callLinkDetailsViewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.annotation.StringRes;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
||||
public enum WebRtcAudioOutput {
|
||||
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_24),
|
||||
HANDSET(R.string.WebRtcAudioOutputToggle__phone_earpiece, R.drawable.ic_handset_solid_28),
|
||||
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.symbol_speaker_fill_white_24),
|
||||
BLUETOOTH_HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.symbol_speaker_bluetooth_fill_white_24),
|
||||
WIRED_HEADSET(R.string.WebRtcAudioOutputToggle__wired_headset, R.drawable.symbol_headphones_filed_24);
|
||||
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -86,7 +86,7 @@ class WebRtcAudioOutputBottomSheet : ComposeBottomSheetDialogFragment(), DialogI
|
||||
|
||||
@Composable
|
||||
fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDeviceId: Int, modifier: Modifier = Modifier.fillMaxWidth(), onDeviceSelected: (AudioOutputOption) -> Unit) {
|
||||
var selectedDeviceId by rememberSaveable { mutableStateOf(initialDeviceId) }
|
||||
var selectedDeviceId by rememberSaveable { mutableIntStateOf(initialDeviceId) }
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = modifier
|
||||
@@ -94,6 +94,7 @@ fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDevi
|
||||
Text(
|
||||
text = stringResource(R.string.WebRtcAudioOutputToggle__audio_output),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
)
|
||||
@@ -127,6 +128,7 @@ fun DeviceList(audioOutputOptions: ImmutableList<AudioOutputOption>, initialDevi
|
||||
Text(
|
||||
text = device.friendlyName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,21 +86,12 @@ class WebRtcAudioOutputToggleButton @JvmOverloads constructor(context: Context,
|
||||
|
||||
val currentOutput = outputState.getCurrentOutput()
|
||||
|
||||
val numberOfOutputs = outputState.getOutputs().size
|
||||
val extra = if (numberOfOutputs < SHOW_PICKER_THRESHOLD) {
|
||||
when (currentOutput) {
|
||||
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
|
||||
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_on)
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected) // should never be seen in practice.
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected) // should never be seen in practice.
|
||||
}
|
||||
} else {
|
||||
when (currentOutput) {
|
||||
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_handset_selected)
|
||||
WebRtcAudioOutput.SPEAKER -> intArrayOf(R.attr.state_speaker_selected)
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
|
||||
}
|
||||
val shouldShowDropdownForSpeaker = outputState.getOutputs().size >= SHOW_PICKER_THRESHOLD || !outputState.getOutputs().contains(WebRtcAudioOutput.HANDSET)
|
||||
val extra = when (currentOutput) {
|
||||
WebRtcAudioOutput.HANDSET -> intArrayOf(R.attr.state_speaker_off)
|
||||
WebRtcAudioOutput.SPEAKER -> if (shouldShowDropdownForSpeaker) intArrayOf(R.attr.state_speaker_selected) else intArrayOf(R.attr.state_speaker_on)
|
||||
WebRtcAudioOutput.BLUETOOTH_HEADSET -> intArrayOf(R.attr.state_bt_headset_selected)
|
||||
WebRtcAudioOutput.WIRED_HEADSET -> intArrayOf(R.attr.state_wired_headset_selected)
|
||||
}
|
||||
|
||||
Log.i(TAG, "Switching to $currentOutput")
|
||||
|
||||
@@ -66,7 +66,7 @@ class CallLinkIncomingRequestSheet : ComposeBottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun isDarkTheme(): Boolean = true
|
||||
override val forceDarkTheme = true
|
||||
|
||||
private val recipientId: RecipientId by lazy {
|
||||
requireArguments().getParcelableCompat(RECIPIENT_ID, RecipientId::class.java)!!
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
@@ -16,16 +17,16 @@ import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
|
||||
protected open fun isDarkTheme(): Boolean = DynamicTheme.isDarkTheme(requireContext())
|
||||
protected open val forceDarkTheme = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return ComposeView(requireContext()).apply {
|
||||
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||
setContent {
|
||||
SignalTheme(
|
||||
isDarkMode = isDarkTheme()
|
||||
isDarkMode = forceDarkTheme || DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
|
||||
Surface(shape = RoundedCornerShape(18.dp, 18.dp), color = SignalTheme.colors.colorSurface1) {
|
||||
SheetContent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.compose
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.animation.ArgbEvaluatorCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.WindowUtil
|
||||
|
||||
/**
|
||||
* Controls status-bar color based off ability to scroll up
|
||||
*/
|
||||
class StatusBarColorAnimator(
|
||||
private val activity: Activity
|
||||
) {
|
||||
private var animator: ValueAnimator? = null
|
||||
private var previousCanScrollUp: Boolean = false
|
||||
|
||||
private val normalColor = ContextCompat.getColor(activity, R.color.signal_colorBackground)
|
||||
private val scrollColor = ContextCompat.getColor(activity, R.color.signal_colorSurface2)
|
||||
|
||||
fun setCanScrollUp(canScrollUp: Boolean) {
|
||||
if (previousCanScrollUp == canScrollUp) {
|
||||
return
|
||||
}
|
||||
|
||||
previousCanScrollUp = canScrollUp
|
||||
applyState(canScrollUp)
|
||||
}
|
||||
|
||||
fun setColorImmediate() {
|
||||
val end = when {
|
||||
previousCanScrollUp -> scrollColor
|
||||
else -> normalColor
|
||||
}
|
||||
|
||||
animator?.cancel()
|
||||
WindowUtil.setStatusBarColor(
|
||||
activity.window,
|
||||
end
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyState(canScrollUp: Boolean) {
|
||||
val (start, end) = when {
|
||||
canScrollUp -> normalColor to scrollColor
|
||||
else -> scrollColor to normalColor
|
||||
}
|
||||
|
||||
animator?.cancel()
|
||||
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 200
|
||||
addUpdateListener {
|
||||
WindowUtil.setStatusBarColor(
|
||||
activity.window,
|
||||
ArgbEvaluatorCompat.getInstance().evaluate(it.animatedFraction, start, end)
|
||||
)
|
||||
}
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,8 @@ object ContactDiscovery {
|
||||
ContactDiscoveryRefreshV2.refreshAll(context, useCompat = FeatureFlags.cdsCompatMode())
|
||||
},
|
||||
removeSystemContactLinksIfMissing = true,
|
||||
notifyOfNewUsers = notifyOfNewUsers
|
||||
notifyOfNewUsers = notifyOfNewUsers,
|
||||
forceFullSystemContactSync = true
|
||||
)
|
||||
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
@@ -140,7 +141,8 @@ object ContactDiscovery {
|
||||
descriptor: String,
|
||||
refresh: () -> RefreshResult,
|
||||
removeSystemContactLinksIfMissing: Boolean,
|
||||
notifyOfNewUsers: Boolean
|
||||
notifyOfNewUsers: Boolean,
|
||||
forceFullSystemContactSync: Boolean = false
|
||||
): RefreshResult {
|
||||
val stopwatch = Stopwatch(descriptor)
|
||||
|
||||
@@ -153,7 +155,7 @@ object ContactDiscovery {
|
||||
if (hasContactsPermissions(context)) {
|
||||
ApplicationDependencies.getJobManager().add(SyncSystemContactLinksJob())
|
||||
|
||||
val useFullSync = removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD
|
||||
val useFullSync = forceFullSystemContactSync || (removeSystemContactLinksIfMissing && result.registeredIds.size > FULL_SYSTEM_CONTACT_SYNC_THRESHOLD)
|
||||
syncRecipientsWithSystemContacts(
|
||||
context = context,
|
||||
rewrites = result.rewrites,
|
||||
|
||||
@@ -206,9 +206,11 @@ object ContactDiscoveryRefreshV2 {
|
||||
*/
|
||||
@WorkerThread
|
||||
private fun Set<RecipientId>.removePossiblyRegisteredButUnlisted(): Set<RecipientId> {
|
||||
val selfId = Recipient.self().id
|
||||
return this - Recipient.resolvedList(this)
|
||||
.filter { it.hasServiceId() }
|
||||
.filter { hasCommunicatedWith(it) }
|
||||
.filter {
|
||||
(it.hasServiceId() && hasCommunicatedWith(it)) || it.id == selfId
|
||||
}
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
|
||||
@@ -326,7 +326,7 @@ public class ConversationAdapter
|
||||
if (conversationMessage == null) return -1;
|
||||
|
||||
if (displayMode.getScheduleMessageMode()) {
|
||||
calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
|
||||
calendar.setTimeInMillis(((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
|
||||
} else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
|
||||
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
|
||||
} else {
|
||||
@@ -346,7 +346,7 @@ public class ConversationAdapter
|
||||
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
|
||||
|
||||
if (displayMode.getScheduleMessageMode()) {
|
||||
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
|
||||
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
|
||||
} else if (displayMode == ConversationItemDisplayMode.EditHistory.INSTANCE) {
|
||||
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
|
||||
} else {
|
||||
|
||||
@@ -105,9 +105,8 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.MediaTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Quote;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
@@ -1132,7 +1131,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
sharedContactStub.get().setContact(((MediaMmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
|
||||
sharedContactStub.get().setContact(((MmsMessageRecord) messageRecord).getSharedContacts().get(0), glideRequests, locale);
|
||||
sharedContactStub.get().setEventListener(sharedContactEventListener);
|
||||
sharedContactStub.get().setOnClickListener(sharedContactClickListener);
|
||||
sharedContactStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
@@ -1219,7 +1218,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
paymentViewStub.setVisibility(View.GONE);
|
||||
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MediaMmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||
audioViewStub.get().setAudio(Objects.requireNonNull(((MmsMessageRecord) messageRecord).getSlideDeck().getAudioSlide()), new AudioViewCallbacks(), showControls, true);
|
||||
audioViewStub.get().setDownloadClickListener(singleDownloadClickListener);
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
|
||||
@@ -1249,7 +1248,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
//noinspection ConstantConditions
|
||||
documentViewStub.get().setDocument(
|
||||
((MediaMmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(),
|
||||
((MmsMessageRecord) messageRecord).getSlideDeck().getDocumentSlide(),
|
||||
showControls,
|
||||
displayMode != ConversationItemDisplayMode.Detailed.INSTANCE
|
||||
);
|
||||
@@ -1381,7 +1380,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (giftViewStub.resolved()) giftViewStub.get().setVisibility(View.GONE);
|
||||
if (joinCallLinkStub.resolved()) joinCallLinkStub.get().setVisibility(View.GONE);
|
||||
|
||||
MediaMmsMessageRecord mediaMmsMessageRecord = (MediaMmsMessageRecord) messageRecord;
|
||||
MmsMessageRecord mediaMmsMessageRecord = (MmsMessageRecord) messageRecord;
|
||||
|
||||
paymentViewStub.setVisibility(View.VISIBLE);
|
||||
paymentViewStub.get().bindPayment(conversationRecipient.get(), Objects.requireNonNull(mediaMmsMessageRecord.getPayment()), colorizer);
|
||||
@@ -1592,9 +1591,9 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (quoteView == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
Quote quote = ((MediaMmsMessageRecord) current).getQuote();
|
||||
Quote quote = ((MmsMessageRecord) current).getQuote();
|
||||
|
||||
if (((MediaMmsMessageRecord) current).getParentStoryId() != null) {
|
||||
if (((MmsMessageRecord) current).getParentStoryId() != null) {
|
||||
quoteView.setMessageType(current.isOutgoing() ? QuoteView.MessageType.STORY_REPLY_OUTGOING : QuoteView.MessageType.STORY_REPLY_INCOMING);
|
||||
} else {
|
||||
quoteView.setMessageType(current.isOutgoing() ? QuoteView.MessageType.OUTGOING : QuoteView.MessageType.INCOMING);
|
||||
@@ -2244,7 +2243,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
|
||||
@Override
|
||||
public @Nullable Projection getOpenableGiftProjection(boolean isAnimating) {
|
||||
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || (messageRecord.getViewedReceiptCount() > 0 && !isAnimating)) {
|
||||
if (!isGiftMessage(messageRecord) || messageRecord.isRemoteDelete() || (messageRecord.isViewed() && !isAnimating)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import org.thoughtcrime.securesms.database.BodyRangeUtil;
|
||||
import org.thoughtcrime.securesms.database.MentionUtil;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
@@ -161,7 +161,7 @@ public class ConversationMessage {
|
||||
}
|
||||
|
||||
public static @NonNull FormattedDate getFormattedDate(@NonNull Context context, @NonNull MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.isScheduled(messageRecord) ? new FormattedDate(false, DateUtils.getOnlyTimeString(context, Locale.getDefault(), ((MediaMmsMessageRecord) messageRecord).getScheduledDate()))
|
||||
return MessageRecordUtil.isScheduled(messageRecord) ? new FormattedDate(false, DateUtils.getOnlyTimeString(context, ((MmsMessageRecord) messageRecord).getScheduledDate()))
|
||||
: DateUtils.getDatelessRelativeTimeSpanFormattedDate(context, Locale.getDefault(), messageRecord.getTimestamp());
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.ThreadTable
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
@@ -119,14 +118,6 @@ internal object ConversationOptionsMenu {
|
||||
}
|
||||
}
|
||||
menuInflater.inflate(R.menu.conversation_group_options, menu)
|
||||
if (!recipient.isPushGroup) {
|
||||
menuInflater.inflate(R.menu.conversation_mms_group_options, menu)
|
||||
if (distributionType == ThreadTable.DistributionTypes.BROADCAST) {
|
||||
menu.findItem(R.id.menu_distribution_broadcast).isChecked = true
|
||||
} else {
|
||||
menu.findItem(R.id.menu_distribution_conversation).isChecked = true
|
||||
}
|
||||
}
|
||||
menuInflater.inflate(R.menu.conversation_active_group_options, menu)
|
||||
}
|
||||
|
||||
@@ -216,8 +207,6 @@ internal object ConversationOptionsMenu {
|
||||
R.id.menu_search -> callback.handleSearch()
|
||||
R.id.menu_add_to_contacts -> callback.handleAddToContacts()
|
||||
R.id.menu_group_recipients -> callback.handleDisplayGroupRecipients()
|
||||
R.id.menu_distribution_broadcast -> callback.handleDistributionBroadcastEnabled(menuItem)
|
||||
R.id.menu_distribution_conversation -> callback.handleDistributionConversationEnabled(menuItem)
|
||||
R.id.menu_group_settings -> callback.handleManageGroup()
|
||||
R.id.menu_leave -> callback.handleLeavePushGroup()
|
||||
R.id.menu_invite -> callback.handleInviteLink()
|
||||
@@ -283,8 +272,6 @@ internal object ConversationOptionsMenu {
|
||||
fun handleSearch()
|
||||
fun handleAddToContacts()
|
||||
fun handleDisplayGroupRecipients()
|
||||
fun handleDistributionBroadcastEnabled(menuItem: MenuItem)
|
||||
fun handleDistributionConversationEnabled(menuItem: MenuItem)
|
||||
fun handleManageGroup()
|
||||
fun handleLeavePushGroup()
|
||||
fun handleInviteLink()
|
||||
|
||||
@@ -41,7 +41,6 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -716,7 +715,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
|
||||
}
|
||||
|
||||
if (FeatureFlags.editMessageSending() && menuState.shouldShowEditAction()) {
|
||||
if (menuState.shouldShowEditAction()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@ package org.thoughtcrime.securesms.conversation;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
|
||||
@@ -167,15 +166,15 @@ public final class MenuState {
|
||||
MessageRecord messageRecord = multiSelectRecord.getMessageRecord();
|
||||
|
||||
builder.shouldShowResendAction(messageRecord.isFailed())
|
||||
.shouldShowSaveAttachmentAction(mediaIsSelected &&
|
||||
!actionMessage &&
|
||||
!viewOnce &&
|
||||
messageRecord.isMms() &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
!messageRecord.isMmsNotification() &&
|
||||
((MediaMmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||
((MediaMmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
||||
.shouldShowSaveAttachmentAction(mediaIsSelected &&
|
||||
!actionMessage &&
|
||||
!viewOnce &&
|
||||
messageRecord.isMms() &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
!messageRecord.isMmsNotification() &&
|
||||
((MmsMessageRecord)messageRecord).containsMediaSlide() &&
|
||||
((MmsMessageRecord)messageRecord).getSlideDeck().getStickerSlide() == null)
|
||||
.shouldShowForwardAction(shouldShowForwardAction)
|
||||
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
|
||||
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.util.Locale
|
||||
|
||||
class ScheduleMessageContextMenu {
|
||||
|
||||
@@ -24,7 +23,7 @@ class ScheduleMessageContextMenu {
|
||||
val scheduledTimes = getNextScheduleTimes(currentTime)
|
||||
val actionItems = scheduledTimes.map {
|
||||
if (it > 0) {
|
||||
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
|
||||
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, it)) {
|
||||
action(it)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -61,6 +61,10 @@ class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialog
|
||||
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
|
||||
}
|
||||
|
||||
if (scheduledLocalDateTime.isBefore(LocalDateTime.now())) {
|
||||
scheduledLocalDateTime.plusDays(1)
|
||||
}
|
||||
|
||||
scheduledHour = scheduledLocalDateTime.hour
|
||||
scheduledMinute = scheduledLocalDateTime.minute
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user