mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-15 22:43:19 +01:00
Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e3d100599 | ||
|
|
a7193e321c | ||
|
|
fa15469696 | ||
|
|
58b9cdf28f | ||
|
|
8e05fe3b0c | ||
|
|
af063b2e9e | ||
|
|
5cc85cc860 | ||
|
|
eafa1eabee | ||
|
|
34a1838668 | ||
|
|
df83c94180 | ||
|
|
e102b60923 | ||
|
|
02900eaa6d | ||
|
|
5ed4c51582 | ||
|
|
81e928f94e | ||
|
|
985b569d29 | ||
|
|
d2d000ef16 | ||
|
|
520b3a14bc | ||
|
|
157d194cc5 | ||
|
|
2785609481 | ||
|
|
6e5e60173b | ||
|
|
f37e938f17 | ||
|
|
da645acd1c | ||
|
|
17205b2baf | ||
|
|
b5ba4d3570 | ||
|
|
17b24d3c24 | ||
|
|
044454dca2 | ||
|
|
88bff9ab6c | ||
|
|
203fde60d6 | ||
|
|
82956c4149 | ||
|
|
1f41b9e481 | ||
|
|
945921fa9a | ||
|
|
7d5786ea93 | ||
|
|
6be1413d7d | ||
|
|
fd07ab10ee | ||
|
|
6232656ad4 | ||
|
|
8493c7ffe5 | ||
|
|
15700b85cb | ||
|
|
3dfd1c98ba | ||
|
|
9a249b0dec | ||
|
|
b74a431ac9 | ||
|
|
880ce18fd0 | ||
|
|
6279149cb8 | ||
|
|
f5c5a34798 | ||
|
|
e9a616c68d | ||
|
|
f5ee7160cb | ||
|
|
cea671aab5 | ||
|
|
da84cde6da | ||
|
|
e9fbce4e28 | ||
|
|
913605a065 | ||
|
|
4bf49df6fa | ||
|
|
91a9d6c68f | ||
|
|
a477c3c4d9 | ||
|
|
0cdd56e0ac | ||
|
|
abefb894cc | ||
|
|
97d482c1ad | ||
|
|
d3e9303d6d | ||
|
|
df7bb13752 | ||
|
|
d28f6f5922 | ||
|
|
c90ad7c1e2 | ||
|
|
7fbdcb8a88 | ||
|
|
d46daed49a | ||
|
|
f18a03ee6d | ||
|
|
1d052e7c1b | ||
|
|
2611165f21 | ||
|
|
f059aa7407 | ||
|
|
ac27df1f0e | ||
|
|
76b28593ea | ||
|
|
0940c88c20 | ||
|
|
c3408040fc | ||
|
|
d2ffc11749 | ||
|
|
4d640ec467 | ||
|
|
c409d49f14 | ||
|
|
2c0dbf1062 | ||
|
|
25f0208e61 | ||
|
|
d063cfe36a | ||
|
|
5c089e1d77 | ||
|
|
867006d29c | ||
|
|
6a974c48ef | ||
|
|
c314918c6b | ||
|
|
e2e2a076c7 | ||
|
|
8ee12b9f26 | ||
|
|
7377293f81 | ||
|
|
29ae49b5f1 | ||
|
|
195d967b3f | ||
|
|
eac74bf9c1 | ||
|
|
9f2dbf7b6c | ||
|
|
9e836ba586 | ||
|
|
cc6dc1b3a2 | ||
|
|
f49da2c9bf | ||
|
|
96c1077238 | ||
|
|
8d72b27e1d | ||
|
|
0ea0d139dd | ||
|
|
b81ff4d672 | ||
|
|
f380ac5e43 | ||
|
|
962d42292d | ||
|
|
15df15556d | ||
|
|
6b29841cc8 | ||
|
|
4f4c1a9bb8 | ||
|
|
5f7630b906 | ||
|
|
8a831889f9 | ||
|
|
bce133ac28 | ||
|
|
f5215d715a | ||
|
|
fde0f3bba1 | ||
|
|
e7b18bd3a2 | ||
|
|
e5e86e639a | ||
|
|
f44b44a354 | ||
|
|
b3399b5242 | ||
|
|
7d4ebd9d3b | ||
|
|
3bb2131375 | ||
|
|
d7314ec2a4 | ||
|
|
cc6c724ee8 | ||
|
|
d3b0559b72 | ||
|
|
1e24caec31 | ||
|
|
65cdc143da | ||
|
|
5d612f020c | ||
|
|
ccef2cc178 | ||
|
|
9337160583 | ||
|
|
bf9d570c3d | ||
|
|
306b0096be | ||
|
|
45583ea469 | ||
|
|
15c6c372ba | ||
|
|
770a89507a | ||
|
|
ddc9aa7506 | ||
|
|
a7d9fd19d9 | ||
|
|
091f7c49ab | ||
|
|
b443f59078 | ||
|
|
27bcf92e9b | ||
|
|
31100c3d82 | ||
|
|
119da2e76e | ||
|
|
588a6cf74f | ||
|
|
eb6394eb6a | ||
|
|
76de183ec2 | ||
|
|
ba31ceb3e7 | ||
|
|
e94e0f8a6b | ||
|
|
f8283acfae | ||
|
|
f8cb26ca74 | ||
|
|
190b9da6c7 | ||
|
|
f84b46148c | ||
|
|
12db8b5ee1 | ||
|
|
05b5078aa9 | ||
|
|
85b7ee85f3 | ||
|
|
326b728d4b | ||
|
|
2e45e131b1 | ||
|
|
1aa95c057b | ||
|
|
6de7a849b3 | ||
|
|
268091b10e | ||
|
|
3920c85ab7 | ||
|
|
524565f0bb | ||
|
|
69c1c856d9 | ||
|
|
dd62d92ffb | ||
|
|
f7e89d75a4 | ||
|
|
023f31eadd | ||
|
|
da8df5beac | ||
|
|
f3a8825cb9 | ||
|
|
835fd47482 | ||
|
|
efbd5cab85 | ||
|
|
a6b7d0bcc5 | ||
|
|
e06126d889 | ||
|
|
4bf8e2c488 | ||
|
|
1c55ad21a3 | ||
|
|
3a601e1e65 | ||
|
|
c953003c2f | ||
|
|
18de51a531 | ||
|
|
ab6d3b5e8d | ||
|
|
151980c6de | ||
|
|
375527b765 | ||
|
|
2978e567d4 | ||
|
|
8ad50ab61c | ||
|
|
2145ded2f2 |
2
.github/workflows/android.yml
vendored
2
.github/workflows/android.yml
vendored
@@ -18,6 +18,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
|
||||
2
.github/workflows/diffuse.yml
vendored
2
.github/workflows/diffuse.yml
vendored
@@ -15,6 +15,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: set up JDK 17
|
||||
@@ -45,6 +46,7 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
clean: 'false'
|
||||
|
||||
- name: Build with Gradle
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "libwebp"]
|
||||
path = libwebp
|
||||
url = https://github.com/webmproject/libwebp.git
|
||||
@@ -15,6 +15,12 @@ Truths which we believe to be self-evident:
|
||||
1. **There is no such thing as time.** Protocol ideas that require synchronized clocks are doomed to failure.
|
||||
|
||||
|
||||
## Building
|
||||
|
||||
1. You'll need to get the `libwebp` submodule after checking out the repository with `git submodule init && git submodule update`
|
||||
1. Most things are pretty straightforward, and opening the project in Android Studio should get you most of the way there.
|
||||
1. Depending on your configuration, you'll also likely need to install additional SDK Tool components, namely the versions of NDK and CMake we are currently using in our [Docker](https://github.com/signalapp/Signal-Android/blob/main/reproducible-builds/Dockerfile#L30) configuration.
|
||||
|
||||
## Issues
|
||||
|
||||
### Useful bug reports
|
||||
|
||||
@@ -3,7 +3,6 @@ import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
id 'com.google.protobuf'
|
||||
id 'androidx.navigation.safeargs'
|
||||
id 'org.jlleitschuh.gradle.ktlint'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
@@ -16,21 +15,6 @@ plugins {
|
||||
|
||||
apply from: 'static-ips.gradle'
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = 'com.google.protobuf:protoc:3.18.0'
|
||||
}
|
||||
generateProtoTasks {
|
||||
all().each { task ->
|
||||
task.builtins {
|
||||
java {
|
||||
option "lite"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wire {
|
||||
kotlin {
|
||||
javaInterop = true
|
||||
@@ -49,8 +33,8 @@ ktlint {
|
||||
version = "0.49.1"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1330
|
||||
def canonicalVersionName = "6.33.2"
|
||||
def canonicalVersionCode = 1345
|
||||
def canonicalVersionName = "6.36.3"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -536,13 +520,11 @@ dependencies {
|
||||
implementation project(':sms-exporter')
|
||||
implementation project(':sticky-header-grid')
|
||||
implementation project(':photoview')
|
||||
implementation project(':glide-webp')
|
||||
|
||||
implementation libs.libsignal.android
|
||||
implementation libs.google.protobuf.javalite
|
||||
|
||||
implementation(libs.mobilecoin) {
|
||||
exclude group: 'com.google.protobuf'
|
||||
}
|
||||
implementation libs.mobilecoin
|
||||
|
||||
implementation libs.signal.ringrtc
|
||||
|
||||
|
||||
@@ -26,9 +26,7 @@ class SignalInstrumentationApplicationContext : ApplicationContext() {
|
||||
}
|
||||
|
||||
override fun initializeLogging() {
|
||||
persistentLogger = PersistentLogger(this)
|
||||
|
||||
Log.initialize({ true }, AndroidLogger(), persistentLogger, inMemoryLogger)
|
||||
Log.initialize({ true }, AndroidLogger(), PersistentLogger(this), inMemoryLogger)
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(CustomSignalProtocolLogger())
|
||||
|
||||
|
||||
@@ -144,6 +144,7 @@ class ConversationItemPreviewer {
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
Optional.of("/not-there.jpg"),
|
||||
false,
|
||||
false,
|
||||
|
||||
@@ -47,7 +47,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.SINGLE
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -69,7 +68,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.END
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(now),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -91,7 +89,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.START
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(prev),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -115,7 +112,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.MIDDLE
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(now),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -137,7 +133,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.SINGLE
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(now),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -159,7 +154,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.SINGLE
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(prev),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
@@ -183,7 +177,6 @@ class V2ConversationItemShapeTest {
|
||||
|
||||
val expected = V2ConversationItemShape.MessageShape.SINGLE
|
||||
val actual = testSubject.setMessageShape(
|
||||
isLtr = true,
|
||||
currentMessage = getMessageRecord(now),
|
||||
isGroupThread = false,
|
||||
adapterPosition = 5
|
||||
|
||||
@@ -293,22 +293,22 @@ class GroupTableTest {
|
||||
|
||||
private fun insertPushGroup(
|
||||
members: List<DecryptedMember> = listOf(
|
||||
DecryptedMember.newBuilder()
|
||||
.setAciBytes(harness.self.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
DecryptedMember.Builder()
|
||||
.aciBytes(harness.self.requireAci().toByteString())
|
||||
.joinedAtRevision(0)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setAciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
DecryptedMember.Builder()
|
||||
.aciBytes(Recipient.resolved(harness.others[0]).requireAci().toByteString())
|
||||
.joinedAtRevision(0)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members)
|
||||
.setRevision(0)
|
||||
val decryptedGroupState = DecryptedGroup.Builder()
|
||||
.members(members)
|
||||
.revision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
@@ -317,23 +317,23 @@ class GroupTableTest {
|
||||
private fun insertPushGroupWithSelfAndOthers(others: List<RecipientId>): GroupId {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
|
||||
val selfMember: DecryptedMember = DecryptedMember.newBuilder()
|
||||
.setAciBytes(harness.self.requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
val selfMember: DecryptedMember = DecryptedMember.Builder()
|
||||
.aciBytes(harness.self.requireAci().toByteString())
|
||||
.joinedAtRevision(0)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build()
|
||||
|
||||
val otherMembers: List<DecryptedMember> = others.map { id ->
|
||||
DecryptedMember.newBuilder()
|
||||
.setAciBytes(Recipient.resolved(id).requireAci().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
DecryptedMember.Builder()
|
||||
.aciBytes(Recipient.resolved(id).requireAci().toByteString())
|
||||
.joinedAtRevision(0)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build()
|
||||
}
|
||||
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(listOf(selfMember) + otherMembers)
|
||||
.setRevision(0)
|
||||
val decryptedGroupState = DecryptedGroup.Builder()
|
||||
.members(listOf(selfMember) + otherMembers)
|
||||
.revision(0)
|
||||
.build()
|
||||
|
||||
return groupTable.create(groupMasterKey, decryptedGroupState)!!
|
||||
|
||||
@@ -48,7 +48,7 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
@@ -62,7 +62,7 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
|
||||
@@ -76,13 +76,13 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 2,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
@@ -96,13 +96,13 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 2,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
|
||||
@@ -115,13 +115,13 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 2,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
@@ -140,13 +140,13 @@ class MessageTableTest_gifts {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 2,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val messageId3 = MmsHelper.insert(
|
||||
@@ -165,13 +165,13 @@ class MessageTableTest_gifts {
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 2,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
giftBadge = GiftBadge()
|
||||
)
|
||||
|
||||
val messageId3 = MmsHelper.insert(
|
||||
|
||||
@@ -502,6 +502,18 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni+aci * pni+aci, all provided, aci sessions but not pni sessions, no SSE expected") {
|
||||
given(E164_A, PNI_A, ACI_A, createThread = true, aciSession = true, pniSession = false)
|
||||
given(null, PNI_B, ACI_B, createThread = false, aciSession = true, pniSession = false)
|
||||
|
||||
process(E164_A, PNI_B, ACI_A)
|
||||
|
||||
expect(E164_A, PNI_B, ACI_A)
|
||||
expect(null, null, ACI_B)
|
||||
|
||||
expectNoSessionSwitchoverEvent()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
@@ -1228,7 +1240,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
.use { cursor: Cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
|
||||
ThreadMergeEvent.parseFrom(bytes)
|
||||
ThreadMergeEvent.ADAPTER.decode(bytes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -1246,7 +1258,7 @@ class RecipientTableTest_getAndPossiblyMerge {
|
||||
.use { cursor: Cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val bytes = Base64.decode(cursor.requireNonNullString(MessageTable.BODY))
|
||||
SessionSwitchoverEvent.parseFrom(bytes)
|
||||
SessionSwitchoverEvent.ADAPTER.decode(bytes)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -22,8 +22,9 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.EditMessage
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -67,16 +68,17 @@ class EditMessageSyncProcessorTest {
|
||||
|
||||
val content = MessageContentFuzzer.fuzzTextMessage()
|
||||
val metadata = MessageContentFuzzer.envelopeMetadata(harness.self.id, toRecipient.id)
|
||||
val syncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
|
||||
SignalServiceProtos.SyncMessage.newBuilder().setSent(
|
||||
SignalServiceProtos.SyncMessage.Sent.newBuilder()
|
||||
.setDestinationServiceId(metadata.destinationServiceId.toString())
|
||||
.setTimestamp(originalTimestamp)
|
||||
.setExpirationStartTimestamp(originalTimestamp)
|
||||
.setMessage(content.dataMessage)
|
||||
)
|
||||
val syncContent = Content.Builder().syncMessage(
|
||||
SyncMessage.Builder().sent(
|
||||
SyncMessage.Sent.Builder()
|
||||
.destinationServiceId(metadata.destinationServiceId.toString())
|
||||
.timestamp(originalTimestamp)
|
||||
.expirationStartTimestamp(originalTimestamp)
|
||||
.message(content.dataMessage)
|
||||
.build()
|
||||
).build()
|
||||
).build()
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer)
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage?.expireTimer ?: 0)
|
||||
val syncTextMessage = TestMessage(
|
||||
envelope = MessageContentFuzzer.envelope(originalTimestamp),
|
||||
content = syncContent,
|
||||
@@ -86,18 +88,20 @@ class EditMessageSyncProcessorTest {
|
||||
|
||||
val editTimestamp = originalTimestamp + 200
|
||||
val editedContent = MessageContentFuzzer.fuzzTextMessage()
|
||||
val editSyncContent = SignalServiceProtos.Content.newBuilder().setSyncMessage(
|
||||
SignalServiceProtos.SyncMessage.newBuilder().setSent(
|
||||
SignalServiceProtos.SyncMessage.Sent.newBuilder()
|
||||
.setDestinationServiceId(metadata.destinationServiceId.toString())
|
||||
.setTimestamp(editTimestamp)
|
||||
.setExpirationStartTimestamp(editTimestamp)
|
||||
.setEditMessage(
|
||||
EditMessage.newBuilder()
|
||||
.setDataMessage(editedContent.dataMessage)
|
||||
.setTargetSentTimestamp(originalTimestamp)
|
||||
val editSyncContent = Content.Builder().syncMessage(
|
||||
SyncMessage.Builder().sent(
|
||||
SyncMessage.Sent.Builder()
|
||||
.destinationServiceId(metadata.destinationServiceId.toString())
|
||||
.timestamp(editTimestamp)
|
||||
.expirationStartTimestamp(editTimestamp)
|
||||
.editMessage(
|
||||
EditMessage.Builder()
|
||||
.dataMessage(editedContent.dataMessage)
|
||||
.targetSentTimestamp(originalTimestamp)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.build()
|
||||
).build()
|
||||
).build()
|
||||
|
||||
val syncEditMessage = TestMessage(
|
||||
@@ -109,38 +113,38 @@ class EditMessageSyncProcessorTest {
|
||||
|
||||
testResult.runSync(listOf(syncTextMessage, syncEditMessage))
|
||||
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, content.dataMessage.expireTimer / 1000)
|
||||
SignalDatabase.recipients.setExpireMessages(toRecipient.id, (content.dataMessage?.expireTimer ?: 0) / 1000)
|
||||
val originalTextMessage = OutgoingMessage(
|
||||
threadRecipient = toRecipient,
|
||||
sentTimeMillis = originalTimestamp,
|
||||
body = content.dataMessage.body,
|
||||
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
|
||||
body = content.dataMessage?.body ?: "",
|
||||
expiresIn = content.dataMessage?.expireTimer?.seconds?.inWholeMilliseconds ?: 0,
|
||||
isUrgent = true,
|
||||
isSecure = true,
|
||||
bodyRanges = content.dataMessage.bodyRangesList.toBodyRangeList()
|
||||
bodyRanges = content.dataMessage?.bodyRanges.toBodyRangeList()
|
||||
)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(toRecipient)
|
||||
val originalMessageId = SignalDatabase.messages.insertMessageOutbox(originalTextMessage, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(originalMessageId, true)
|
||||
if (content.dataMessage.expireTimer > 0) {
|
||||
if ((content.dataMessage?.expireTimer ?: 0) > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(originalMessageId, originalTimestamp)
|
||||
}
|
||||
|
||||
val editMessage = OutgoingMessage(
|
||||
threadRecipient = toRecipient,
|
||||
sentTimeMillis = editTimestamp,
|
||||
body = editedContent.dataMessage.body,
|
||||
expiresIn = content.dataMessage.expireTimer.seconds.inWholeMilliseconds,
|
||||
body = editedContent.dataMessage?.body ?: "",
|
||||
expiresIn = content.dataMessage?.expireTimer?.seconds?.inWholeMilliseconds ?: 0,
|
||||
isUrgent = true,
|
||||
isSecure = true,
|
||||
bodyRanges = editedContent.dataMessage.bodyRangesList.toBodyRangeList(),
|
||||
bodyRanges = editedContent.dataMessage?.bodyRanges.toBodyRangeList(),
|
||||
messageToEdit = originalMessageId
|
||||
)
|
||||
|
||||
val editMessageId = SignalDatabase.messages.insertMessageOutbox(editMessage, threadId, false, null)
|
||||
SignalDatabase.messages.markAsSent(editMessageId, true)
|
||||
|
||||
if (content.dataMessage.expireTimer > 0) {
|
||||
if ((content.dataMessage?.expireTimer ?: 0) > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(editMessageId, originalTimestamp)
|
||||
}
|
||||
testResult.collectLocal()
|
||||
@@ -167,7 +171,7 @@ class EditMessageSyncProcessorTest {
|
||||
|
||||
fun runSync(messages: List<TestMessage>) {
|
||||
messages.forEach { (envelope, content, metadata, serverDeliveredTimestamp) ->
|
||||
if (content.hasSyncMessage()) {
|
||||
if (content.syncMessage != null) {
|
||||
processorV2.process(
|
||||
envelope,
|
||||
content,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils
|
||||
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
|
||||
@@ -15,8 +15,8 @@ import org.thoughtcrime.securesms.testing.MessageContentFuzzer
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.util.MessageTableTestUtils
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -41,9 +41,9 @@ class MessageContentProcessor__recipientStatusTest {
|
||||
@Test
|
||||
fun syncGroupSentTextMessageWithRecipientUpdateFollowup() {
|
||||
val (groupId, masterKey, groupRecipientId) = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), harness.others[0].asMember(), harness.others[1].asMember())
|
||||
val groupContextV2 = GroupContextV2.newBuilder().setRevision(0).setMasterKey(masterKey.serialize().toProtoByteString()).build()
|
||||
val groupContextV2 = GroupContextV2.Builder().revision(0).masterKey(masterKey.serialize().toByteString()).build()
|
||||
|
||||
val initialTextMessage = DataMessage.newBuilder().buildWith {
|
||||
val initialTextMessage = DataMessage.Builder().buildWith {
|
||||
body = MessageContentFuzzer.string()
|
||||
groupV2 = groupContextV2
|
||||
timestamp = envelopeTimestamp
|
||||
|
||||
@@ -6,7 +6,6 @@ import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
@@ -26,7 +25,7 @@ import org.thoughtcrime.securesms.testing.Entry
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.awaitFor
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
|
||||
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
|
||||
import java.util.regex.Pattern
|
||||
@@ -93,7 +92,7 @@ class MessageProcessingPerformanceTest {
|
||||
val messageCount = 100
|
||||
val envelopes = generateInboundEnvelopes(bobClient, messageCount)
|
||||
val firstTimestamp = envelopes.first().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp
|
||||
val lastTimestamp = envelopes.last().timestamp ?: 0
|
||||
|
||||
// Inject the envelopes into the websocket
|
||||
Thread {
|
||||
@@ -190,7 +189,7 @@ class MessageProcessingPerformanceTest {
|
||||
path = "/api/v1/message",
|
||||
id = Random(System.currentTimeMillis()).nextLong(),
|
||||
headers = listOf("X-Signal-Timestamp: ${this.timestamp}"),
|
||||
body = this.toByteArray().toByteString()
|
||||
body = this.encodeByteString()
|
||||
)
|
||||
).encodeByteString()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
data class TestMessage(
|
||||
val envelope: SignalServiceProtos.Envelope,
|
||||
val content: SignalServiceProtos.Content,
|
||||
val envelope: Envelope,
|
||||
val content: Content,
|
||||
val metadata: EnvelopeMetadata,
|
||||
val serverDeliveredTimestamp: Long
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.testing.LogPredicate
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(context) {
|
||||
companion object {
|
||||
@@ -19,9 +20,9 @@ class TimingMessageContentProcessor(context: Context) : MessageContentProcessor(
|
||||
fun endTag(timestamp: Long) = "$timestamp end"
|
||||
}
|
||||
|
||||
override fun process(envelope: SignalServiceProtos.Envelope, content: SignalServiceProtos.Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
|
||||
Log.d(TAG, startTag(envelope.timestamp))
|
||||
override fun process(envelope: Envelope, content: Content, metadata: EnvelopeMetadata, serverDeliveredTimestamp: Long, processingEarlyContent: Boolean, localMetric: SignalLocalMetrics.MessageReceive?) {
|
||||
Log.d(TAG, startTag(envelope.timestamp!!))
|
||||
super.process(envelope, content, metadata, serverDeliveredTimestamp, processingEarlyContent, localMetric)
|
||||
Log.d(TAG, endTag(envelope.timestamp))
|
||||
Log.d(TAG, endTag(envelope.timestamp!!))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.FakeClientHelpers.toEnvelope
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
/**
|
||||
* Welcome to Alice's Client.
|
||||
|
||||
@@ -31,11 +31,10 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.UnsupportedOperationException
|
||||
|
||||
/**
|
||||
* Welcome to Bob's Client.
|
||||
@@ -61,7 +60,7 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
/** Inspired by SignalServiceMessageSender#getEncryptedMessage */
|
||||
fun encrypt(now: Long): SignalServiceProtos.Envelope {
|
||||
fun encrypt(now: Long): Envelope {
|
||||
val envelopeContent = FakeClientHelpers.encryptedTextMessage(now)
|
||||
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, null)
|
||||
@@ -72,10 +71,10 @@ class BobClient(val serviceId: ServiceId, val e164: String, val identityKeyPair:
|
||||
}
|
||||
|
||||
return cipher.encrypt(getAliceProtocolAddress(), getAliceUnidentifiedAccess(), envelopeContent)
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage.timestamp, getAliceServiceId())
|
||||
.toEnvelope(envelopeContent.content.get().dataMessage!!.timestamp!!, getAliceServiceId())
|
||||
}
|
||||
|
||||
fun decrypt(envelope: SignalServiceProtos.Envelope, serverDeliveredTimestamp: Long) {
|
||||
fun decrypt(envelope: Envelope, serverDeliveredTimestamp: Long) {
|
||||
val cipher = SignalServiceCipher(serviceAddress, 1, aciStore, sessionLock, UnidentifiedAccessUtil.getCertificateValidator())
|
||||
cipher.decrypt(envelope, serverDeliveredTimestamp)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.libsignal.internal.Native
|
||||
import org.signal.libsignal.internal.NativeHandleGuard
|
||||
import org.signal.libsignal.metadata.certificate.CertificateValidator
|
||||
@@ -9,15 +10,16 @@ import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.ecc.ECKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.whispersystems.signalservice.api.crypto.ContentHint
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.util.Base64
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
@@ -52,9 +54,9 @@ object FakeClientHelpers {
|
||||
}
|
||||
|
||||
fun encryptedTextMessage(now: Long, message: String = "Test body message"): EnvelopeContent {
|
||||
val content = SignalServiceProtos.Content.newBuilder().apply {
|
||||
setDataMessage(
|
||||
SignalServiceProtos.DataMessage.newBuilder().apply {
|
||||
val content = Content.Builder().apply {
|
||||
dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
body = message
|
||||
timestamp = now
|
||||
}
|
||||
@@ -64,16 +66,16 @@ object FakeClientHelpers {
|
||||
}
|
||||
|
||||
fun OutgoingPushMessage.toEnvelope(timestamp: Long, destination: ServiceId): Envelope {
|
||||
return Envelope.newBuilder()
|
||||
.setType(Envelope.Type.valueOf(this.type))
|
||||
.setSourceDevice(1)
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(timestamp + 1)
|
||||
.setDestinationServiceId(destination.toString())
|
||||
.setServerGuid(UUID.randomUUID().toString())
|
||||
.setContent(Base64.decode(this.content).toProtoByteString())
|
||||
.setUrgent(true)
|
||||
.setStory(false)
|
||||
return Envelope.Builder()
|
||||
.type(Envelope.Type.fromValue(this.type))
|
||||
.sourceDevice(1)
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 1)
|
||||
.destinationServiceId(destination.toString())
|
||||
.serverGuid(UUID.randomUUID().toString())
|
||||
.content(Base64.decode(this.content).toByteString())
|
||||
.urgent(true)
|
||||
.story(false)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,19 +16,19 @@ import kotlin.random.Random
|
||||
*/
|
||||
object GroupTestingUtils {
|
||||
fun member(aci: ACI, revision: Int = 0, role: Member.Role = Member.Role.ADMINISTRATOR): DecryptedMember {
|
||||
return DecryptedMember.newBuilder()
|
||||
.setAciBytes(aci.toByteString())
|
||||
.setJoinedAtRevision(revision)
|
||||
.setRole(role)
|
||||
return DecryptedMember.Builder()
|
||||
.aciBytes(aci.toByteString())
|
||||
.joinedAtRevision(revision)
|
||||
.role(role)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun insertGroup(revision: Int = 0, vararg members: DecryptedMember): TestGroupInfo {
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members.toList())
|
||||
.setRevision(revision)
|
||||
.setTitle(MessageContentFuzzer.string())
|
||||
val decryptedGroupState = DecryptedGroup.Builder()
|
||||
.members(members.toList())
|
||||
.revision(revision)
|
||||
.title(MessageContentFuzzer.string())
|
||||
.build()
|
||||
|
||||
val groupId = SignalDatabase.groups.create(groupMasterKey, decryptedGroupState)!!
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.buildWith
|
||||
import org.thoughtcrime.securesms.messages.TestMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
|
||||
import org.whispersystems.signalservice.internal.push.AttachmentPointer
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
import org.whispersystems.signalservice.internal.push.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.random.nextInt
|
||||
@@ -35,10 +34,10 @@ object MessageContentFuzzer {
|
||||
* Create an [Envelope].
|
||||
*/
|
||||
fun envelope(timestamp: Long): Envelope {
|
||||
return Envelope.newBuilder()
|
||||
.setTimestamp(timestamp)
|
||||
.setServerTimestamp(timestamp + 5)
|
||||
.setServerGuidBytes(UuidUtil.toByteString(UUID.randomUUID()))
|
||||
return Envelope.Builder()
|
||||
.timestamp(timestamp)
|
||||
.serverTimestamp(timestamp + 5)
|
||||
.serverGuid(UUID.randomUUID().toString())
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -62,20 +61,22 @@ object MessageContentFuzzer {
|
||||
* - Bold style body ranges
|
||||
*/
|
||||
fun fuzzTextMessage(groupContextV2: GroupContextV2? = null): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().buildWith {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
body = string()
|
||||
if (random.nextBoolean()) {
|
||||
expireTimer = random.nextInt(0..28.days.inWholeSeconds.toInt())
|
||||
}
|
||||
if (random.nextBoolean()) {
|
||||
addBodyRanges(
|
||||
SignalServiceProtos.BodyRange.newBuilder().buildWith {
|
||||
start = 0
|
||||
length = 1
|
||||
style = SignalServiceProtos.BodyRange.Style.BOLD
|
||||
}
|
||||
bodyRanges(
|
||||
listOf(
|
||||
BodyRange.Builder().buildWith {
|
||||
start = 0
|
||||
length = 1
|
||||
style = BodyRange.Style.BOLD
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
if (groupContextV2 != null) {
|
||||
@@ -95,16 +96,16 @@ object MessageContentFuzzer {
|
||||
recipientUpdate: Boolean = false
|
||||
): Content {
|
||||
return Content
|
||||
.newBuilder()
|
||||
.setSyncMessage(
|
||||
SyncMessage.newBuilder().buildWith {
|
||||
sent = SyncMessage.Sent.newBuilder().buildWith {
|
||||
.Builder()
|
||||
.syncMessage(
|
||||
SyncMessage.Builder().buildWith {
|
||||
sent = SyncMessage.Sent.Builder().buildWith {
|
||||
timestamp = textMessage.timestamp
|
||||
message = textMessage
|
||||
isRecipientUpdate = recipientUpdate
|
||||
addAllUnidentifiedStatus(
|
||||
unidentifiedStatus(
|
||||
deliveredTo.map {
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.newBuilder().buildWith {
|
||||
SyncMessage.Sent.UnidentifiedDeliveryStatus.Builder().buildWith {
|
||||
destinationServiceId = Recipient.resolved(it).requireServiceId().toString()
|
||||
unidentified = true
|
||||
}
|
||||
@@ -123,9 +124,9 @@ object MessageContentFuzzer {
|
||||
* - A message with 0-2 attachment pointers and may contain a text body
|
||||
*/
|
||||
fun fuzzMediaMessageWithBody(quoteAble: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().buildWith {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
if (random.nextBoolean()) {
|
||||
body = string()
|
||||
}
|
||||
@@ -133,28 +134,28 @@ object MessageContentFuzzer {
|
||||
if (random.nextBoolean() && quoteAble.isNotEmpty()) {
|
||||
body = string()
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().buildWith {
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = quoted.envelope.timestamp
|
||||
authorAci = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
addAllAttachments(quoted.content.dataMessage.attachmentsList)
|
||||
addAllBodyRanges(quoted.content.dataMessage.bodyRangesList)
|
||||
text = quoted.content.dataMessage?.body
|
||||
attachments(quoted.content.dataMessage?.attachments ?: emptyList())
|
||||
bodyRanges(quoted.content.dataMessage?.bodyRanges ?: emptyList())
|
||||
type = DataMessage.Quote.Type.NORMAL
|
||||
}
|
||||
}
|
||||
|
||||
if (random.nextFloat() < 0.1 && quoteAble.isNotEmpty()) {
|
||||
val quoted = quoteAble.random(random)
|
||||
quote = DataMessage.Quote.newBuilder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp - 1000000, quoted.envelope.timestamp)
|
||||
quote = DataMessage.Quote.Builder().buildWith {
|
||||
id = random.nextLong(quoted.envelope.timestamp!! - 1000000, quoted.envelope.timestamp!!)
|
||||
authorAci = quoted.metadata.sourceServiceId.toString()
|
||||
text = quoted.content.dataMessage.body
|
||||
text = quoted.content.dataMessage?.body
|
||||
}
|
||||
}
|
||||
|
||||
if (random.nextFloat() < 0.25) {
|
||||
val total = random.nextInt(1, 2)
|
||||
(0..total).forEach { _ -> addAttachments(attachmentPointer()) }
|
||||
attachments((0..total).map { attachmentPointer() })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -166,12 +167,12 @@ object MessageContentFuzzer {
|
||||
* - A reaction to a prior message
|
||||
*/
|
||||
fun fuzzMediaMessageNoContent(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().buildWith {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
if (random.nextFloat() < 0.25) {
|
||||
val reactTo = previousMessages.random(random)
|
||||
reaction = DataMessage.Reaction.newBuilder().buildWith {
|
||||
reaction = DataMessage.Reaction.Builder().buildWith {
|
||||
emoji = emojis.random(random)
|
||||
remove = false
|
||||
targetAuthorAci = reactTo.metadata.sourceServiceId.toString()
|
||||
@@ -187,15 +188,15 @@ object MessageContentFuzzer {
|
||||
* - A sticker
|
||||
*/
|
||||
fun fuzzMediaMessageNoText(previousMessages: List<TestMessage> = emptyList()): Content {
|
||||
return Content.newBuilder()
|
||||
.setDataMessage(
|
||||
DataMessage.newBuilder().buildWith {
|
||||
return Content.Builder()
|
||||
.dataMessage(
|
||||
DataMessage.Builder().buildWith {
|
||||
if (random.nextFloat() < 0.9) {
|
||||
sticker = DataMessage.Sticker.newBuilder().buildWith {
|
||||
sticker = DataMessage.Sticker.Builder().buildWith {
|
||||
packId = byteString(length = 24)
|
||||
packKey = byteString(length = 128)
|
||||
stickerId = random.nextInt()
|
||||
data = attachmentPointer()
|
||||
data_ = attachmentPointer()
|
||||
emoji = emojis.random(random)
|
||||
}
|
||||
}
|
||||
@@ -223,14 +224,14 @@ object MessageContentFuzzer {
|
||||
* Generate a random [ByteString].
|
||||
*/
|
||||
fun byteString(length: Int = 512): ByteString {
|
||||
return random.nextBytes(length).toProtoByteString()
|
||||
return random.nextBytes(length).toByteString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random [AttachmentPointer].
|
||||
*/
|
||||
fun attachmentPointer(): AttachmentPointer {
|
||||
return AttachmentPointer.newBuilder().run {
|
||||
return AttachmentPointer.Builder().run {
|
||||
cdnKey = string()
|
||||
contentType = mediaTypes.random(random)
|
||||
key = byteString()
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.AddressProto
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.MetadataProto
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class TestProtos private constructor() {
|
||||
fun address(
|
||||
uuid: UUID = UUID.randomUUID()
|
||||
): AddressProto.Builder {
|
||||
return AddressProto.newBuilder()
|
||||
.setUuid(ACI.from(uuid).toByteString())
|
||||
}
|
||||
|
||||
fun metadata(
|
||||
address: AddressProto = address().build()
|
||||
): MetadataProto.Builder {
|
||||
return MetadataProto.newBuilder()
|
||||
.setAddress(address)
|
||||
}
|
||||
|
||||
fun groupContextV2(
|
||||
revision: Int = 0,
|
||||
masterKeyBytes: ByteArray = Random.Default.nextBytes(GroupMasterKey.SIZE)
|
||||
): GroupContextV2.Builder {
|
||||
return GroupContextV2.newBuilder()
|
||||
.setRevision(revision)
|
||||
.setMasterKey(ByteString.copyFrom(masterKeyBytes))
|
||||
}
|
||||
|
||||
fun storyContext(
|
||||
sentTimestamp: Long = Random.nextLong(),
|
||||
authorUuid: String = UUID.randomUUID().toString()
|
||||
): DataMessage.StoryContext.Builder {
|
||||
return DataMessage.StoryContext.newBuilder()
|
||||
.setAuthorAci(authorUuid)
|
||||
.setSentTimestamp(sentTimestamp)
|
||||
}
|
||||
|
||||
fun dataMessage(): DataMessage.Builder {
|
||||
return DataMessage.newBuilder()
|
||||
}
|
||||
|
||||
fun content(): SignalServiceProtos.Content.Builder {
|
||||
return SignalServiceProtos.Content.newBuilder()
|
||||
}
|
||||
|
||||
fun serviceContent(
|
||||
localAddress: AddressProto = address().build(),
|
||||
metadata: MetadataProto = metadata().build()
|
||||
): SignalServiceContentProto.Builder {
|
||||
return SignalServiceContentProto.newBuilder()
|
||||
.setLocalAddress(localAddress)
|
||||
.setMetadata(metadata)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <T> build(buildFn: TestProtos.() -> T): T {
|
||||
return TestProtos().buildFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,7 @@ object TestMessages {
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
Optional.of("/not-there.jpg"),
|
||||
false,
|
||||
false,
|
||||
@@ -171,6 +172,7 @@ object TestMessages {
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
0,
|
||||
Optional.of("/not-there.aac"),
|
||||
true,
|
||||
false,
|
||||
|
||||
@@ -252,6 +252,7 @@
|
||||
|
||||
<activity-alias android:name=".RoutingActivity"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
@@ -630,6 +631,7 @@
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:parentActivityName=".MainActivity"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
@@ -1029,6 +1031,7 @@
|
||||
<activity android:name=".MainActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:resizeableActivity="true"
|
||||
android:exported="false"/>
|
||||
|
||||
<activity android:name=".pin.PinRestoreActivity"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,8 +90,6 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.PowerManagerCompat;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
@@ -124,9 +122,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
private static final String TAG = Log.tag(ApplicationContext.class);
|
||||
|
||||
@VisibleForTesting
|
||||
protected PersistentLogger persistentLogger;
|
||||
|
||||
public static ApplicationContext getInstance(Context context) {
|
||||
return (ApplicationContext)context.getApplicationContext();
|
||||
}
|
||||
@@ -265,10 +260,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
MemoryTracker.stop();
|
||||
}
|
||||
|
||||
public PersistentLogger getPersistentLogger() {
|
||||
return persistentLogger;
|
||||
}
|
||||
|
||||
public void checkBuildExpiration() {
|
||||
if (Util.getTimeUntilBuildExpiry() <= 0 && !SignalStore.misc().isClientDeprecated()) {
|
||||
Log.w(TAG, "Build expired!");
|
||||
@@ -295,8 +286,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
|
||||
@VisibleForTesting
|
||||
protected void initializeLogging() {
|
||||
persistentLogger = new PersistentLogger(this);
|
||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||
Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), new PersistentLogger(this));
|
||||
|
||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.content.Intent;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.transition.TransitionInflater;
|
||||
import android.view.View;
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.content.Intent;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.ActionBar;
|
||||
@@ -18,7 +17,6 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.ConfigurationUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
@@ -50,6 +49,7 @@ import androidx.transition.TransitionManager;
|
||||
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.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
@@ -73,7 +73,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -85,6 +84,7 @@ import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
@@ -519,6 +519,10 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
}
|
||||
|
||||
public void setQueryFilter(String filter) {
|
||||
if (Objects.equals(filter, this.cursorFilter)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cursorFilter = filter;
|
||||
contactSearchMediator.onFilterChanged(filter);
|
||||
}
|
||||
@@ -542,10 +546,6 @@ public final class ContactSelectionListFragment extends LoggingFragment {
|
||||
headerActionView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
|
||||
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
|
||||
}
|
||||
|
||||
private void onLoadFinished(int count) {
|
||||
swipeRefresh.setVisibility(View.VISIBLE);
|
||||
showContactsLayout.setVisibility(View.GONE);
|
||||
|
||||
@@ -5,13 +5,11 @@ import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Vibrator;
|
||||
import android.text.TextUtils;
|
||||
import android.transition.TransitionInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
|
||||
@@ -117,9 +117,9 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
PromptBatterySaverDialogFragment.show(getSupportFragmentManager());
|
||||
break;
|
||||
case PROMPT_DEBUGLOGS_FOR_NOTIFICATIONS:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
|
||||
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.NOTIFICATIONS);
|
||||
case PROMPT_DEBUGLOGS_FOR_CRASH:
|
||||
DebugLogsPromptDialogFragment.show(this, getSupportFragmentManager(), DebugLogsPromptDialogFragment.Purpose.CRASH);
|
||||
DebugLogsPromptDialogFragment.show(this, DebugLogsPromptDialogFragment.Purpose.CRASH);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import android.app.KeyguardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.InputType;
|
||||
|
||||
@@ -53,11 +53,12 @@ import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
@@ -99,6 +100,7 @@ import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -775,6 +777,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.isCallLink()) {
|
||||
CallLinkProfileKeySender.onSendAnyway(new HashSet<>(changedRecipients));
|
||||
}
|
||||
|
||||
if (state.getGroupCallState().isConnected()) {
|
||||
ApplicationDependencies.getSignalCallManager().groupApproveSafetyChange(changedRecipients);
|
||||
} else {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package org.thoughtcrime.securesms.absbackup.backupables
|
||||
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.absbackup.AndroidBackupItem
|
||||
import org.thoughtcrime.securesms.absbackup.protos.SvrAuthToken
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* This backs up the not-secret KBS Auth tokens, which can be combined with a PIN to prove ownership of a phone number in order to complete the registration process.
|
||||
@@ -30,7 +30,7 @@ object SvrAuthTokens : AndroidBackupItem {
|
||||
val proto = SvrAuthToken.ADAPTER.decode(data)
|
||||
|
||||
SignalStore.svr().putAuthTokenList(proto.tokens)
|
||||
} catch (e: InvalidProtocolBufferException) {
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Cannot restore KbsAuthToken from backup service.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
|
||||
public abstract class Attachment {
|
||||
import java.util.Objects;
|
||||
|
||||
public abstract class Attachment implements Parcelable {
|
||||
|
||||
@NonNull
|
||||
private final String contentType;
|
||||
@@ -48,6 +59,7 @@ public abstract class Attachment {
|
||||
private final int height;
|
||||
private final boolean quote;
|
||||
private final long uploadTimestamp;
|
||||
private final int incrementalMacChunkSize;
|
||||
|
||||
@Nullable
|
||||
private final String caption;
|
||||
@@ -80,6 +92,7 @@ public abstract class Attachment {
|
||||
boolean videoGif,
|
||||
int width,
|
||||
int height,
|
||||
int incrementalMacChunkSize,
|
||||
boolean quote,
|
||||
long uploadTimestamp,
|
||||
@Nullable String caption,
|
||||
@@ -88,31 +101,95 @@ public abstract class Attachment {
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
this.transferState = transferState;
|
||||
this.size = size;
|
||||
this.fileName = fileName;
|
||||
this.cdnNumber = cdnNumber;
|
||||
this.location = location;
|
||||
this.key = key;
|
||||
this.relay = relay;
|
||||
this.digest = digest;
|
||||
this.incrementalDigest = incrementalDigest;
|
||||
this.fastPreflightId = fastPreflightId;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.videoGif = videoGif;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.quote = quote;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.audioHash = audioHash;
|
||||
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
|
||||
this.contentType = contentType;
|
||||
this.transferState = transferState;
|
||||
this.size = size;
|
||||
this.fileName = fileName;
|
||||
this.cdnNumber = cdnNumber;
|
||||
this.location = location;
|
||||
this.key = key;
|
||||
this.relay = relay;
|
||||
this.digest = digest;
|
||||
this.incrementalDigest = incrementalDigest;
|
||||
this.fastPreflightId = fastPreflightId;
|
||||
this.voiceNote = voiceNote;
|
||||
this.borderless = borderless;
|
||||
this.videoGif = videoGif;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.incrementalMacChunkSize = incrementalMacChunkSize;
|
||||
this.quote = quote;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.stickerLocator = stickerLocator;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.audioHash = audioHash;
|
||||
this.transformProperties = transformProperties != null ? transformProperties : TransformProperties.empty();
|
||||
}
|
||||
|
||||
protected Attachment(Parcel in) {
|
||||
this.contentType = Objects.requireNonNull(in.readString());
|
||||
this.transferState = in.readInt();
|
||||
this.size = in.readLong();
|
||||
this.fileName = in.readString();
|
||||
this.cdnNumber = in.readInt();
|
||||
this.location = in.readString();
|
||||
this.key = in.readString();
|
||||
this.relay = in.readString();
|
||||
this.digest = ParcelUtil.readByteArray(in);
|
||||
this.incrementalDigest = ParcelUtil.readByteArray(in);
|
||||
this.fastPreflightId = in.readString();
|
||||
this.voiceNote = ParcelUtil.readBoolean(in);
|
||||
this.borderless = ParcelUtil.readBoolean(in);
|
||||
this.videoGif = ParcelUtil.readBoolean(in);
|
||||
this.width = in.readInt();
|
||||
this.height = in.readInt();
|
||||
this.incrementalMacChunkSize = in.readInt();
|
||||
this.quote = ParcelUtil.readBoolean(in);
|
||||
this.uploadTimestamp = in.readLong();
|
||||
this.stickerLocator = ParcelCompat.readParcelable(in, StickerLocator.class.getClassLoader(), StickerLocator.class);
|
||||
this.caption = in.readString();
|
||||
this.blurHash = ParcelCompat.readParcelable(in, BlurHash.class.getClassLoader(), BlurHash.class);
|
||||
this.audioHash = ParcelCompat.readParcelable(in, AudioHash.class.getClassLoader(), AudioHash.class);
|
||||
this.transformProperties = Objects.requireNonNull(ParcelCompat.readParcelable(in, TransformProperties.class.getClassLoader(), TransformProperties.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
AttachmentCreator.writeSubclass(dest, this);
|
||||
dest.writeString(contentType);
|
||||
dest.writeInt(transferState);
|
||||
dest.writeLong(size);
|
||||
dest.writeString(fileName);
|
||||
dest.writeInt(cdnNumber);
|
||||
dest.writeString(location);
|
||||
dest.writeString(key);
|
||||
dest.writeString(relay);
|
||||
ParcelUtil.writeByteArray(dest, digest);
|
||||
ParcelUtil.writeByteArray(dest, incrementalDigest);
|
||||
dest.writeString(fastPreflightId);
|
||||
ParcelUtil.writeBoolean(dest, voiceNote);
|
||||
ParcelUtil.writeBoolean(dest, borderless);
|
||||
ParcelUtil.writeBoolean(dest, videoGif);
|
||||
dest.writeInt(width);
|
||||
dest.writeInt(height);
|
||||
dest.writeInt(incrementalMacChunkSize);
|
||||
ParcelUtil.writeBoolean(dest, quote);
|
||||
dest.writeLong(uploadTimestamp);
|
||||
dest.writeParcelable(stickerLocator, 0);
|
||||
dest.writeString(caption);
|
||||
dest.writeParcelable(blurHash, 0);
|
||||
dest.writeParcelable(audioHash, 0);
|
||||
dest.writeParcelable(transformProperties, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<Attachment> CREATOR = AttachmentCreator.INSTANCE;
|
||||
|
||||
@Nullable
|
||||
public abstract Uri getUri();
|
||||
|
||||
@@ -172,7 +249,11 @@ public abstract class Attachment {
|
||||
|
||||
@Nullable
|
||||
public byte[] getIncrementalDigest() {
|
||||
return incrementalDigest;
|
||||
if (incrementalDigest != null && incrementalDigest.length > 0) {
|
||||
return incrementalDigest;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -200,6 +281,10 @@ public abstract class Attachment {
|
||||
return height;
|
||||
}
|
||||
|
||||
public int getIncrementalMacChunkSize() {
|
||||
return incrementalMacChunkSize;
|
||||
}
|
||||
|
||||
public boolean isQuote() {
|
||||
return quote;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.attachments
|
||||
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
|
||||
/**
|
||||
* Parcelable Creator for Attachments. Encapsulates the logic around dealing with
|
||||
* subclasses, since Attachment is abstract and has several children.
|
||||
*/
|
||||
object AttachmentCreator : Parcelable.Creator<Attachment> {
|
||||
enum class Subclass(val clazz: Class<out Attachment>, val code: String) {
|
||||
DATABASE(DatabaseAttachment::class.java, "database"),
|
||||
MMS_NOTIFICATION(MmsNotificationAttachment::class.java, "mms_notification"),
|
||||
POINTER(PointerAttachment::class.java, "pointer"),
|
||||
TOMBSTONE(TombstoneAttachment::class.java, "tombstone"),
|
||||
URI(UriAttachment::class.java, "uri")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun writeSubclass(dest: Parcel, instance: Attachment) {
|
||||
val subclass = Subclass.values().firstOrNull { it.clazz == instance::class.java } ?: error("Unexpected subtype ${instance::class.java.simpleName}")
|
||||
dest.writeString(subclass.code)
|
||||
}
|
||||
|
||||
override fun createFromParcel(source: Parcel): Attachment {
|
||||
val rawCode = source.readString()!!
|
||||
|
||||
return when (Subclass.values().first { rawCode == it.code }) {
|
||||
Subclass.DATABASE -> DatabaseAttachment(source)
|
||||
Subclass.MMS_NOTIFICATION -> MmsNotificationAttachment(source)
|
||||
Subclass.POINTER -> PointerAttachment(source)
|
||||
Subclass.TOMBSTONE -> TombstoneAttachment(source)
|
||||
Subclass.URI -> UriAttachment(source)
|
||||
}
|
||||
}
|
||||
|
||||
override fun newArray(size: Int): Array<Attachment?> = arrayOfNulls(size)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
@@ -10,6 +13,7 @@ import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
|
||||
import java.util.Comparator;
|
||||
|
||||
@@ -35,6 +39,7 @@ public class DatabaseAttachment extends Attachment {
|
||||
String relay,
|
||||
byte[] digest,
|
||||
byte[] incrementalDigest,
|
||||
int incrementalMacChunkSize,
|
||||
String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
@@ -50,7 +55,7 @@ public class DatabaseAttachment extends Attachment {
|
||||
int displayOrder,
|
||||
long uploadTimestamp)
|
||||
{
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
super(contentType, transferProgress, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, quote, uploadTimestamp, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.attachmentId = attachmentId;
|
||||
this.hasData = hasData;
|
||||
this.hasThumbnail = hasThumbnail;
|
||||
@@ -58,6 +63,25 @@ public class DatabaseAttachment extends Attachment {
|
||||
this.displayOrder = displayOrder;
|
||||
}
|
||||
|
||||
protected DatabaseAttachment(Parcel in) {
|
||||
super(in);
|
||||
this.attachmentId = ParcelCompat.readParcelable(in, AttachmentId.class.getClassLoader(), AttachmentId.class);
|
||||
this.hasData = ParcelUtil.readBoolean(in);
|
||||
this.hasThumbnail = ParcelUtil.readBoolean(in);
|
||||
this.mmsId = in.readLong();
|
||||
this.displayOrder = in.readInt();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
super.writeToParcel(dest, flags);
|
||||
dest.writeParcelable(attachmentId, 0);
|
||||
ParcelUtil.writeBoolean(dest, hasData);
|
||||
ParcelUtil.writeBoolean(dest, hasThumbnail);
|
||||
dest.writeLong(mmsId);
|
||||
dest.writeInt(displayOrder);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Uri getUri() {
|
||||
|
||||
@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
@@ -11,7 +13,11 @@ import org.thoughtcrime.securesms.database.MessageTable;
|
||||
public class MmsNotificationAttachment extends Attachment {
|
||||
|
||||
public MmsNotificationAttachment(int status, long size) {
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, false, 0, null, null, null, null, null);
|
||||
super("application/mms", getTransferStateFromStatus(status), size, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, false, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
protected MmsNotificationAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -13,7 +14,7 @@ import org.whispersystems.signalservice.api.InvalidMessageStructureException;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||
import org.whispersystems.signalservice.api.util.AttachmentPointerUtil;
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -31,6 +32,7 @@ public class PointerAttachment extends Attachment {
|
||||
@Nullable String relay,
|
||||
@Nullable byte[] digest,
|
||||
@Nullable byte[] incrementalDigest,
|
||||
int incrementalMacChunkSize,
|
||||
@Nullable String fastPreflightId,
|
||||
boolean voiceNote,
|
||||
boolean borderless,
|
||||
@@ -42,7 +44,11 @@ public class PointerAttachment extends Attachment {
|
||||
@Nullable StickerLocator stickerLocator,
|
||||
@Nullable BlurHash blurHash)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
super(contentType, transferState, size, fileName, cdnNumber, location, key, relay, digest, incrementalDigest, fastPreflightId, voiceNote, borderless, videoGif, width, height, incrementalMacChunkSize, false, uploadTimestamp, caption, stickerLocator, blurHash, null, null);
|
||||
}
|
||||
|
||||
protected PointerAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@@ -111,9 +117,11 @@ public class PointerAttachment extends Attachment {
|
||||
pointer.get().asPointer().getFileName().orElse(null),
|
||||
pointer.get().asPointer().getCdnNumber(),
|
||||
pointer.get().asPointer().getRemoteId().toString(),
|
||||
encodedKey, null,
|
||||
encodedKey,
|
||||
null,
|
||||
pointer.get().asPointer().getDigest().orElse(null),
|
||||
pointer.get().asPointer().getIncrementalDigest().orElse(null),
|
||||
pointer.get().asPointer().getIncrementalMacChunkSize(),
|
||||
fastPreflightId,
|
||||
pointer.get().asPointer().getVoiceNote(),
|
||||
pointer.get().asPointer().isBorderless(),
|
||||
@@ -140,6 +148,7 @@ public class PointerAttachment extends Attachment {
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
@@ -152,24 +161,25 @@ public class PointerAttachment extends Attachment {
|
||||
null));
|
||||
}
|
||||
|
||||
public static Optional<Attachment> forPointer(SignalServiceProtos.DataMessage.Quote.QuotedAttachment quotedAttachment) {
|
||||
public static Optional<Attachment> forPointer(DataMessage.Quote.QuotedAttachment quotedAttachment) {
|
||||
SignalServiceAttachment thumbnail;
|
||||
try {
|
||||
thumbnail = quotedAttachment.hasThumbnail() ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.getThumbnail()) : null;
|
||||
thumbnail = quotedAttachment.thumbnail != null ? AttachmentPointerUtil.createSignalAttachmentPointer(quotedAttachment.thumbnail) : null;
|
||||
} catch (InvalidMessageStructureException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(quotedAttachment.getContentType(),
|
||||
return Optional.of(new PointerAttachment(quotedAttachment.contentType,
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||
quotedAttachment.getFileName(),
|
||||
quotedAttachment.fileName,
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
||||
null,
|
||||
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalDigest().orElse(null) : null,
|
||||
thumbnail != null ? thumbnail.asPointer().getIncrementalMacChunkSize() : 0,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -16,7 +17,11 @@ import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
public class TombstoneAttachment extends Attachment {
|
||||
|
||||
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
|
||||
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, null, false, false, false, 0, 0, 0, quote, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
protected TombstoneAttachment(Parcel in) {
|
||||
super(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.attachments;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Parcel;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.os.ParcelCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
@@ -52,10 +54,21 @@ public class UriAttachment extends Attachment {
|
||||
@Nullable AudioHash audioHash,
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, 0, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.dataUri = Objects.requireNonNull(dataUri);
|
||||
}
|
||||
|
||||
protected UriAttachment(Parcel in) {
|
||||
super(in);
|
||||
this.dataUri = Objects.requireNonNull(ParcelCompat.readParcelable(in, Uri.class.getClassLoader(), Uri.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(@NonNull Parcel dest, int flags) {
|
||||
super.writeToParcel(dest, flags);
|
||||
dest.writeParcelable(dataUri, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
public Uri getUri() {
|
||||
|
||||
@@ -2,19 +2,19 @@ package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
public class AudioFileInfo {
|
||||
private final long durationUs;
|
||||
private final byte[] waveFormBytes;
|
||||
private final float[] waveForm;
|
||||
|
||||
public static @NonNull AudioFileInfo fromDatabaseProtobuf(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
return new AudioFileInfo(audioWaveForm.getDurationUs(), audioWaveForm.getWaveForm().toByteArray());
|
||||
return new AudioFileInfo(audioWaveForm.durationUs, audioWaveForm.waveForm.toByteArray());
|
||||
}
|
||||
|
||||
AudioFileInfo(long durationUs, byte[] waveFormBytes) {
|
||||
@@ -37,9 +37,9 @@ public class AudioFileInfo {
|
||||
}
|
||||
|
||||
public @NonNull AudioWaveFormData toDatabaseProtobuf() {
|
||||
return AudioWaveFormData.newBuilder()
|
||||
.setDurationUs(durationUs)
|
||||
.setWaveForm(ByteString.copyFrom(waveFormBytes))
|
||||
.build();
|
||||
return new AudioWaveFormData.Builder()
|
||||
.durationUs(durationUs)
|
||||
.waveForm(ByteString.of(waveFormBytes))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.util.ParcelUtil;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* An AudioHash is a compact string representation of the wave form and duration for an audio file.
|
||||
*/
|
||||
public final class AudioHash {
|
||||
public final class AudioHash implements Parcelable {
|
||||
|
||||
@NonNull private final String hash;
|
||||
@NonNull private final AudioWaveFormData audioWaveForm;
|
||||
@@ -22,13 +27,46 @@ public final class AudioHash {
|
||||
}
|
||||
|
||||
public AudioHash(@NonNull AudioWaveFormData audioWaveForm) {
|
||||
this(Base64.encodeBytes(audioWaveForm.toByteArray()), audioWaveForm);
|
||||
this(Base64.encodeBytes(audioWaveForm.encode()), audioWaveForm);
|
||||
}
|
||||
|
||||
protected AudioHash(Parcel in) {
|
||||
hash = Objects.requireNonNull(in.readString());
|
||||
|
||||
try {
|
||||
audioWaveForm = AudioWaveFormData.ADAPTER.decode(Objects.requireNonNull(ParcelUtil.readByteArray(in)));
|
||||
} catch (IOException e) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(hash);
|
||||
ParcelUtil.writeByteArray(dest, audioWaveForm.encode());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static final Creator<AudioHash> CREATOR = new Creator<>() {
|
||||
@Override
|
||||
public AudioHash createFromParcel(Parcel in) {
|
||||
return new AudioHash(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioHash[] newArray(int size) {
|
||||
return new AudioHash[size];
|
||||
}
|
||||
};
|
||||
|
||||
public static @Nullable AudioHash parseOrNull(@Nullable String hash) {
|
||||
if (hash == null) return null;
|
||||
try {
|
||||
return new AudioHash(hash, AudioWaveFormData.parseFrom(Base64.decode(hash)));
|
||||
return new AudioHash(hash, AudioWaveFormData.ADAPTER.decode(Base64.decode(hash)));
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ object AudioWaveForms {
|
||||
private fun generateWaveForm(context: Context, uri: Uri, cacheKey: String, attachmentId: AttachmentId): CacheCheckResult {
|
||||
try {
|
||||
val startTime = System.currentTimeMillis()
|
||||
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData.getDefaultInstance())
|
||||
SignalDatabase.attachments.writeAudioHash(attachmentId, AudioWaveFormData())
|
||||
|
||||
Log.i(TAG, "Starting wave form generation ($cacheKey)")
|
||||
val fileInfo: AudioFileInfo = AudioWaveFormGenerator.generateWaveForm(context, uri)
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.thoughtcrime.securesms.backup.proto.BackupFrame;
|
||||
@@ -27,6 +28,9 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
|
||||
private final String TAG = Log.tag(BackupRecordInputStream.class);
|
||||
private final int MAX_BUFFER_SIZE = 8192;
|
||||
|
||||
private final int version;
|
||||
private final InputStream in;
|
||||
private final Cipher cipher;
|
||||
@@ -92,6 +96,35 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
return readFrame(in);
|
||||
}
|
||||
|
||||
boolean validateFrame() throws InvalidAlgorithmParameterException, IOException, InvalidKeyException {
|
||||
int frameLength = decryptFrameLength(in);
|
||||
if (frameLength <= 0) {
|
||||
Log.i(TAG, "Backup frame is not valid due to negative frame length. This is likely because the decryption passphrase was wrong.");
|
||||
return false;
|
||||
}
|
||||
|
||||
int bufferSize = Math.min(MAX_BUFFER_SIZE, frameLength);
|
||||
byte[] buffer = new byte[bufferSize];
|
||||
byte[] theirMac = new byte[10];
|
||||
while (frameLength > 0) {
|
||||
int read = in.read(buffer, 0, Math.min(buffer.length, frameLength));
|
||||
if (read == -1) return false;
|
||||
|
||||
if (read < MAX_BUFFER_SIZE) {
|
||||
final int frameEndIndex = read - 10;
|
||||
mac.update(buffer, 0, frameEndIndex);
|
||||
System.arraycopy(buffer, frameEndIndex, theirMac, 0, theirMac.length);
|
||||
} else {
|
||||
mac.update(buffer, 0, read);
|
||||
}
|
||||
frameLength -= read;
|
||||
}
|
||||
|
||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
||||
|
||||
return MessageDigest.isEqual(ourMac, theirMac);
|
||||
}
|
||||
|
||||
void readAttachmentTo(OutputStream out, int length) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
@@ -142,24 +175,7 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
|
||||
private BackupFrame readFrame(InputStream in) throws IOException {
|
||||
try {
|
||||
byte[] length = new byte[4];
|
||||
StreamUtil.readFully(in, length);
|
||||
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
int frameLength;
|
||||
if (BackupVersions.isFrameLengthEncrypted(version)) {
|
||||
mac.update(length);
|
||||
// this depends upon cipher being a stream cipher mode in order to get back the length without needing a full AES block-size input
|
||||
byte[] decryptedLength = cipher.update(length);
|
||||
if (decryptedLength.length != length.length) {
|
||||
throw new IOException("Cipher was not a stream cipher!");
|
||||
}
|
||||
frameLength = Conversions.byteArrayToInt(decryptedLength);
|
||||
} else {
|
||||
frameLength = Conversions.byteArrayToInt(length);
|
||||
}
|
||||
int frameLength = decryptFrameLength(in);
|
||||
|
||||
byte[] frame = new byte[frameLength];
|
||||
StreamUtil.readFully(in, frame);
|
||||
@@ -182,5 +198,27 @@ class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
}
|
||||
}
|
||||
|
||||
private int decryptFrameLength(InputStream inputStream) throws IOException, InvalidAlgorithmParameterException, InvalidKeyException {
|
||||
byte[] length = new byte[4];
|
||||
StreamUtil.readFully(inputStream, length);
|
||||
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
int frameLength;
|
||||
if (BackupVersions.isFrameLengthEncrypted(version)) {
|
||||
mac.update(length);
|
||||
// this depends upon cipher being a stream cipher mode in order to get back the length without needing a full AES block-size input
|
||||
byte[] decryptedLength = cipher.update(length);
|
||||
if (decryptedLength.length != length.length) {
|
||||
throw new IOException("Cipher was not a stream cipher!");
|
||||
}
|
||||
frameLength = Conversions.byteArrayToInt(decryptedLength);
|
||||
} else {
|
||||
frameLength = Conversions.byteArrayToInt(length);
|
||||
}
|
||||
return frameLength;
|
||||
}
|
||||
|
||||
static class BadMacException extends IOException {}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
@@ -63,6 +65,24 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(FullBackupImporter.class);
|
||||
|
||||
public static boolean validatePassphrase(@NonNull Context context,
|
||||
@NonNull Uri uri,
|
||||
@NonNull String passphrase)
|
||||
throws IOException
|
||||
{
|
||||
|
||||
try (InputStream is = getInputStream(context, uri)) {
|
||||
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
|
||||
return inputStream.validateFrame();
|
||||
} catch (InvalidAlgorithmParameterException e) {
|
||||
Log.w(TAG, "Invalid algorithm parameter exception in backup passphrase validation.", e);
|
||||
return false;
|
||||
} catch (InvalidKeyException e) {
|
||||
Log.w(TAG, "Invalid key exception in backup passphrase validation.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
|
||||
throws IOException
|
||||
|
||||
@@ -101,16 +101,16 @@ object Badges {
|
||||
|
||||
@JvmStatic
|
||||
fun toDatabaseBadge(badge: Badge): BadgeList.Badge {
|
||||
return BadgeList.Badge.newBuilder()
|
||||
.setId(badge.id)
|
||||
.setCategory(badge.category.code)
|
||||
.setDescription(badge.description)
|
||||
.setExpiration(badge.expirationTimestamp)
|
||||
.setVisible(badge.visible)
|
||||
.setName(badge.name)
|
||||
.setImageUrl(badge.imageUrl.toString())
|
||||
.setImageDensity(badge.imageDensity)
|
||||
.build()
|
||||
return BadgeList.Badge(
|
||||
id = badge.id,
|
||||
category = badge.category.code,
|
||||
description = badge.description,
|
||||
expiration = badge.expirationTimestamp,
|
||||
visible = badge.visible,
|
||||
name = badge.name,
|
||||
imageUrl = badge.imageUrl.toString(),
|
||||
imageDensity = badge.imageDensity
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
||||
@@ -79,12 +79,11 @@ class GiftMessageView @JvmOverloads constructor(
|
||||
}
|
||||
|
||||
actionView.setText(
|
||||
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
|
||||
when (giftBadge.redemptionState) {
|
||||
GiftBadge.RedemptionState.PENDING -> R.string.GiftMessageView__redeem
|
||||
GiftBadge.RedemptionState.STARTED -> R.string.GiftMessageView__redeeming
|
||||
GiftBadge.RedemptionState.REDEEMED -> R.string.GiftMessageView__redeemed
|
||||
GiftBadge.RedemptionState.FAILED -> R.string.GiftMessageView__redeem
|
||||
GiftBadge.RedemptionState.UNRECOGNIZED -> R.string.GiftMessageView__redeem
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ object Gifts {
|
||||
): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = recipient,
|
||||
body = Base64.encodeBytes(giftBadge.toByteArray()),
|
||||
body = Base64.encodeBytes(giftBadge.encode()),
|
||||
isSecure = true,
|
||||
sentTimeMillis = sentTimestamp,
|
||||
expiresIn = expiresIn,
|
||||
|
||||
@@ -83,7 +83,7 @@ class GiftFlowConfirmationFragment :
|
||||
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.GIFT)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.GIFT)
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
@@ -106,6 +106,7 @@ class GiftFlowConfirmationFragment :
|
||||
GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToGatewaySelectorBottomSheet(
|
||||
with(viewModel.snapshot) {
|
||||
GatewayRequest(
|
||||
uiSessionKey = viewModel.uiSessionKey,
|
||||
donateToSignalType = DonateToSignalType.GIFT,
|
||||
badge = giftBadge!!,
|
||||
label = getString(R.string.preferences__one_time),
|
||||
@@ -262,6 +263,10 @@ class GiftFlowConfirmationFragment :
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(GiftFlowConfirmationFragmentDirections.actionGiftFlowConfirmationFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
val mainActivityIntent = MainActivity.clearTop(requireContext())
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class GiftFlowViewModel(
|
||||
val state: Flowable<GiftFlowState> = store.stateFlowable
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val snapshot: GiftFlowState get() = store.state
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
|
||||
init {
|
||||
refresh()
|
||||
|
||||
@@ -63,7 +63,7 @@ class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
ViewReceivedGiftBottomSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_SENT_FROM, messageRecord.fromRecipient.id)
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.encode())
|
||||
putLong(ARG_MESSAGE_ID, messageRecord.id)
|
||||
}
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
|
||||
@@ -34,7 +34,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
ViewSentGiftBottomSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_SENT_TO, messageRecord.toRecipient.id)
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.encode())
|
||||
}
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
get() = requireArguments().getParcelableCompat(ARG_SENT_TO, RecipientId::class.java)!!
|
||||
|
||||
private val giftBadge: GiftBadge
|
||||
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
|
||||
get() = GiftBadge.ADAPTER.decode(requireArguments().getByteArray(ARG_GIFT_BADGE)!!)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.core.app.SharedElementCallback
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.transition.TransitionInflater
|
||||
@@ -57,6 +58,7 @@ import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.doAfterNextLayout
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
@@ -74,7 +76,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
private val TAG = Log.tag(CallLogFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: CallLogViewModel by viewModels()
|
||||
private val viewModel: CallLogViewModel by activityViewModels()
|
||||
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val callLogContextMenu = CallLogContextMenu(this, this)
|
||||
@@ -230,6 +232,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
val count = callLogActionMode.getCount()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, count, count))
|
||||
.setMessage(
|
||||
if (FeatureFlags.adHocCalling()) {
|
||||
getString(R.string.CallLogFragment__call_links_youve_created)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(count, viewModel.stageSelectionDeletion())
|
||||
callLogActionMode.end()
|
||||
@@ -303,6 +312,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
val progress = 1 - verticalOffset.toFloat() / -layout.height
|
||||
binding.pullView.onUserDrag(progress)
|
||||
}
|
||||
|
||||
if (viewModel.filterSnapshot != CallLogFilter.ALL) {
|
||||
binding.root.doAfterNextLayout {
|
||||
binding.pullView.openImmediate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateACallLinkClicked() {
|
||||
@@ -363,6 +378,13 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
override fun deleteCall(call: CallLogRow) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getQuantityString(R.plurals.CallLogFragment__delete_d_calls, 1, 1))
|
||||
.setMessage(
|
||||
if (FeatureFlags.adHocCalling()) {
|
||||
getString(R.string.CallLogFragment__call_links_youve_created)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
.setPositiveButton(R.string.CallLogFragment__delete) { _, _ ->
|
||||
performDeletion(1, viewModel.stageCallDeletion(call))
|
||||
}
|
||||
|
||||
@@ -86,18 +86,22 @@ class CallLogRepository(
|
||||
/**
|
||||
* Delete all call events / unowned links and enqueue clear history job, and then
|
||||
* emit a clear history message.
|
||||
*
|
||||
* This explicitly drops failed call link revocations of call links, and those call links
|
||||
* will remain visible to the user. This is safe because the clear history sync message should
|
||||
* only clear local history and then poll link status from the server.
|
||||
*/
|
||||
fun deleteAllCallLogsOnOrBeforeNow(): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.rawDatabase.withinTransaction {
|
||||
val now = System.currentTimeMillis()
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(now)
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(now)
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(now))
|
||||
val latestTimestamp = SignalDatabase.calls.getLatestTimestamp()
|
||||
SignalDatabase.calls.deleteNonAdHocCallEventsOnOrBefore(latestTimestamp)
|
||||
SignalDatabase.callLinks.deleteNonAdminCallLinksOnOrBefore(latestTimestamp)
|
||||
ApplicationDependencies.getJobManager().add(CallLogEventSendJob.forClearHistory(latestTimestamp))
|
||||
}
|
||||
|
||||
SignalDatabase.callLinks.getAllAdminCallLinksExcept(emptySet())
|
||||
}.flatMap(this::revokeAndCollectResults).map { -1 }.subscribeOn(Schedulers.io())
|
||||
}.flatMap(this::revokeAndCollectResults).map { 0 }.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -93,11 +93,11 @@ sealed class CallLogRow {
|
||||
return FULL
|
||||
}
|
||||
|
||||
if (groupCallUpdateDetails.inCallUuidsList.contains(Recipient.self().requireAci().rawUuid.toString())) {
|
||||
if (groupCallUpdateDetails.inCallUuids.contains(Recipient.self().requireAci().rawUuid.toString())) {
|
||||
return LOCAL_USER_JOINED
|
||||
}
|
||||
|
||||
return if (groupCallUpdateDetails.inCallUuidsCount > 0) {
|
||||
return if (groupCallUpdateDetails.inCallUuids.isNotEmpty()) {
|
||||
ACTIVE
|
||||
} else {
|
||||
NONE
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
@@ -12,10 +17,12 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
import java.util.List;
|
||||
@@ -24,13 +31,15 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
private @Nullable SlideClickListener thumbnailClickListener;
|
||||
private @Nullable SlidesClickedListener downloadClickListener;
|
||||
private @Nullable SlidesClickedListener cancelDownloadClickListener;
|
||||
private @Nullable SlideClickListener playVideoClickListener;
|
||||
|
||||
private int currentSizeClass;
|
||||
|
||||
private final int[] corners = new int[4];
|
||||
|
||||
private ViewGroup albumCellContainer;
|
||||
private Stub<TransferControlView> transferControls;
|
||||
private final ViewGroup albumCellContainer;
|
||||
private final Stub<TransferControlView> transferControlsStub;
|
||||
|
||||
private final SlideClickListener defaultThumbnailClickListener = (v, slide) -> {
|
||||
if (thumbnailClickListener != null) {
|
||||
@@ -42,19 +51,18 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
public AlbumThumbnailView(@NonNull Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
inflate(getContext(), R.layout.album_thumbnail_view, this);
|
||||
|
||||
albumCellContainer = findViewById(R.id.album_cell_container);
|
||||
transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
|
||||
}
|
||||
|
||||
public AlbumThumbnailView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
inflate(getContext(), R.layout.album_thumbnail_view, this);
|
||||
|
||||
albumCellContainer = findViewById(R.id.album_cell_container);
|
||||
transferControls = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
|
||||
albumCellContainer = findViewById(R.id.album_cell_container);
|
||||
transferControlsStub = new Stub<>(findViewById(R.id.album_transfer_controls_stub));
|
||||
}
|
||||
|
||||
public void setSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides, boolean showControls) {
|
||||
@@ -63,16 +71,17 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
transferControls.get().setShowDownloadText(true);
|
||||
transferControls.get().setSlides(slides);
|
||||
transferControls.get().setDownloadClickListener(v -> {
|
||||
if (downloadClickListener != null) {
|
||||
downloadClickListener.onClick(v, slides);
|
||||
}
|
||||
});
|
||||
transferControlsStub.get().setShowSecondaryText(true);
|
||||
transferControlsStub.get().setDownloadClickListener(
|
||||
v -> {
|
||||
if (downloadClickListener != null) {
|
||||
downloadClickListener.onClick(v, slides);
|
||||
}
|
||||
});
|
||||
transferControlsStub.get().setSlides(slides);
|
||||
} else {
|
||||
if (transferControls.resolved()) {
|
||||
transferControls.get().setVisibility(GONE);
|
||||
if (transferControlsStub.resolved()) {
|
||||
transferControlsStub.get().setVisibility(GONE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +94,7 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
|
||||
showSlides(glideRequests, slides);
|
||||
applyCorners();
|
||||
forceLayout();
|
||||
}
|
||||
|
||||
public void setCellBackgroundColor(@ColorInt int color) {
|
||||
@@ -101,10 +111,19 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
thumbnailClickListener = listener;
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(@Nullable SlidesClickedListener listener) {
|
||||
downloadClickListener = listener;
|
||||
public void setDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.downloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.cancelDownloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setPlayVideoClickListener(SlideClickListener listener) {
|
||||
this.playVideoClickListener = listener;
|
||||
}
|
||||
|
||||
|
||||
public void setRadii(int topLeft, int topRight, int bottomRight, int bottomLeft) {
|
||||
corners[0] = topLeft;
|
||||
corners[1] = topRight;
|
||||
@@ -117,23 +136,46 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
private void inflateLayout(int sizeClass) {
|
||||
albumCellContainer.removeAllViews();
|
||||
|
||||
int resId;
|
||||
switch (sizeClass) {
|
||||
case 2:
|
||||
inflate(getContext(), R.layout.album_thumbnail_2, albumCellContainer);
|
||||
resId = R.layout.album_thumbnail_2;
|
||||
break;
|
||||
case 3:
|
||||
inflate(getContext(), R.layout.album_thumbnail_3, albumCellContainer);
|
||||
resId = R.layout.album_thumbnail_3;
|
||||
break;
|
||||
case 4:
|
||||
inflate(getContext(), R.layout.album_thumbnail_4, albumCellContainer);
|
||||
resId = R.layout.album_thumbnail_4;
|
||||
break;
|
||||
case 5:
|
||||
inflate(getContext(), R.layout.album_thumbnail_5, albumCellContainer);
|
||||
resId = R.layout.album_thumbnail_5;
|
||||
break;
|
||||
default:
|
||||
inflate(getContext(), R.layout.album_thumbnail_many, albumCellContainer);
|
||||
resId = R.layout.album_thumbnail_many;
|
||||
break;
|
||||
}
|
||||
|
||||
inflate(getContext(), resId, albumCellContainer);
|
||||
if (transferControlsStub.resolved()) {
|
||||
int size;
|
||||
switch (sizeClass) {
|
||||
case 2:
|
||||
size = R.dimen.album_2_total_height;
|
||||
break;
|
||||
case 3:
|
||||
size = R.dimen.album_3_total_height;
|
||||
break;
|
||||
case 4:
|
||||
size = R.dimen.album_4_total_height;
|
||||
break;
|
||||
default:
|
||||
size = R.dimen.album_5_total_height;
|
||||
break;
|
||||
}
|
||||
ViewGroup.LayoutParams params = transferControlsStub.get().getLayoutParams();
|
||||
params.height = getContext().getResources().getDimensionPixelSize(size);
|
||||
transferControlsStub.get().setLayoutParams(params);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyCorners() {
|
||||
@@ -214,19 +256,20 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
private void showSlides(@NonNull GlideRequests glideRequests, @NonNull List<Slide> slides) {
|
||||
setSlide(glideRequests, slides.get(0), R.id.album_cell_1);
|
||||
setSlide(glideRequests, slides.get(1), R.id.album_cell_2);
|
||||
boolean showControls = TransferControlView.containsPlayableSlides(slides);
|
||||
setSlide(glideRequests, slides.get(0), R.id.album_cell_1, showControls);
|
||||
setSlide(glideRequests, slides.get(1), R.id.album_cell_2, showControls);
|
||||
|
||||
if (slides.size() >= 3) {
|
||||
setSlide(glideRequests, slides.get(2), R.id.album_cell_3);
|
||||
setSlide(glideRequests, slides.get(2), R.id.album_cell_3, showControls);
|
||||
}
|
||||
|
||||
if (slides.size() >= 4) {
|
||||
setSlide(glideRequests, slides.get(3), R.id.album_cell_4);
|
||||
setSlide(glideRequests, slides.get(3), R.id.album_cell_4, showControls);
|
||||
}
|
||||
|
||||
if (slides.size() >= 5) {
|
||||
setSlide(glideRequests, slides.get(4), R.id.album_cell_5);
|
||||
setSlide(glideRequests, slides.get(4), R.id.album_cell_5, showControls && slides.size() == 5);
|
||||
}
|
||||
|
||||
if (slides.size() > 5) {
|
||||
@@ -235,11 +278,17 @@ public class AlbumThumbnailView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id) {
|
||||
private void setSlide(@NonNull GlideRequests glideRequests, @NonNull Slide slide, @IdRes int id, boolean showControls) {
|
||||
ThumbnailView cell = findViewById(id);
|
||||
cell.setImageResource(glideRequests, slide, false, false);
|
||||
cell.showSecondaryText(false);
|
||||
cell.setThumbnailClickListener(defaultThumbnailClickListener);
|
||||
cell.setDownloadClickListener(downloadClickListener);
|
||||
cell.setCancelDownloadClickListener(cancelDownloadClickListener);
|
||||
if (MediaUtil.isInstantVideoSupported(slide)) {
|
||||
cell.setPlayVideoClickListener(playVideoClickListener);
|
||||
}
|
||||
cell.setOnLongClickListener(defaultLongClickListener);
|
||||
cell.setImageResource(glideRequests, slide, showControls, false);
|
||||
}
|
||||
|
||||
private int sizeClass(int size) {
|
||||
|
||||
@@ -15,12 +15,16 @@ import androidx.annotation.Px;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.MultiTransformation;
|
||||
import com.bumptech.glide.load.Transformation;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.engine.GlideException;
|
||||
import com.bumptech.glide.load.resource.bitmap.CircleCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.SimpleTarget;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.bumptech.glide.request.transition.Transition;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -32,6 +36,7 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
@@ -54,6 +59,8 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(AvatarImageView.class);
|
||||
|
||||
private final RequestListener<Drawable> redownloadRequestListener = new RedownloadRequestListener();
|
||||
|
||||
private int size;
|
||||
private boolean inverted;
|
||||
private OnClickListener listener;
|
||||
@@ -198,7 +205,8 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
.error(fallbackContactPhotoDrawable)
|
||||
.diskCacheStrategy(DiskCacheStrategy.ALL)
|
||||
.downsample(DownsampleStrategy.CENTER_INSIDE)
|
||||
.transform(new MultiTransformation<>(transforms));
|
||||
.transform(new MultiTransformation<>(transforms))
|
||||
.addListener(redownloadRequestListener);
|
||||
|
||||
if (avatarOptions.fixedSize > 0) {
|
||||
fixedSizeTarget = new FixedSizeTarget(avatarOptions.fixedSize);
|
||||
@@ -363,4 +371,19 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class RedownloadRequestListener implements RequestListener<Drawable> {
|
||||
@Override
|
||||
public boolean onLoadFailed(@Nullable GlideException e, Object model, Target<Drawable> target, boolean isFirstResource) {
|
||||
if (model instanceof ProfileContactPhoto) {
|
||||
RetrieveProfileAvatarJob.enqueueForceUpdate(((ProfileContactPhoto) model).getRecipient());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onResourceReady(Drawable resource, Object model, Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
@@ -86,7 +91,7 @@ class ConversationItemThumbnail @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(): Parcelable? {
|
||||
override fun onSaveInstanceState(): Parcelable {
|
||||
val root = super.onSaveInstanceState()
|
||||
return bundleOf(
|
||||
STATE_ROOT to root,
|
||||
@@ -255,9 +260,19 @@ class ConversationItemThumbnail @JvmOverloads constructor(
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
fun setProgressWheelClickListener(listener: SlideClickListener?) {
|
||||
fun setPlayVideoClickListener(listener: SlideClickListener?) {
|
||||
state = state.copy(
|
||||
thumbnailViewState = state.thumbnailViewState.copy(progressWheelClickListener = listener)
|
||||
thumbnailViewState = state.thumbnailViewState.copy(playVideoClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(playVideoClickListener = listener)
|
||||
)
|
||||
|
||||
state.applyState(thumbnail, album)
|
||||
}
|
||||
|
||||
fun setCancelDownloadClickListener(listener: SlidesClickedListener?) {
|
||||
state = state.copy(
|
||||
thumbnailViewState = state.thumbnailViewState.copy(cancelDownloadClickListener = listener),
|
||||
albumViewState = state.albumViewState.copy(cancelDownloadClickListener = listener)
|
||||
)
|
||||
|
||||
state.applyState(thumbnail, album)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.graphics.Color
|
||||
@@ -31,7 +36,9 @@ data class ConversationItemThumbnailState(
|
||||
@IgnoredOnParcel
|
||||
private val downloadClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val progressWheelClickListener: SlideClickListener? = null,
|
||||
private val cancelDownloadClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val playVideoClickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val longClickListener: OnLongClickListener? = null,
|
||||
private val visibility: Int = View.GONE,
|
||||
@@ -57,7 +64,8 @@ data class ConversationItemThumbnailState(
|
||||
thumbnailView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
thumbnailView.get().setThumbnailClickListener(clickListener)
|
||||
thumbnailView.get().setDownloadClickListener(downloadClickListener)
|
||||
thumbnailView.get().setProgressWheelClickListener(progressWheelClickListener)
|
||||
thumbnailView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
|
||||
thumbnailView.get().setPlayVideoClickListener(playVideoClickListener)
|
||||
thumbnailView.get().setOnLongClickListener(longClickListener)
|
||||
thumbnailView.get().setBounds(minWidth, maxWidth, minHeight, maxHeight)
|
||||
}
|
||||
@@ -72,6 +80,10 @@ data class ConversationItemThumbnailState(
|
||||
@IgnoredOnParcel
|
||||
private val downloadClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val cancelDownloadClickListener: SlidesClickedListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val playVideoClickListener: SlideClickListener? = null,
|
||||
@IgnoredOnParcel
|
||||
private val longClickListener: OnLongClickListener? = null,
|
||||
private val visibility: Int = View.GONE,
|
||||
private val cellBackgroundColor: Int = Color.TRANSPARENT,
|
||||
@@ -92,6 +104,8 @@ data class ConversationItemThumbnailState(
|
||||
albumView.get().setRadii(cornerTopLeft, cornerTopRight, cornerBottomRight, cornerBottomLeft)
|
||||
albumView.get().setThumbnailClickListener(clickListener)
|
||||
albumView.get().setDownloadClickListener(downloadClickListener)
|
||||
albumView.get().setCancelDownloadClickListener(cancelDownloadClickListener)
|
||||
albumView.get().setPlayVideoClickListener(playVideoClickListener)
|
||||
albumView.get().setOnLongClickListener(longClickListener)
|
||||
albumView.get().setCellBackgroundColor(cellBackgroundColor)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import org.signal.core.util.ResourceUtil
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
@@ -31,13 +31,17 @@ class DebugLogsPromptDialogFragment : FixedRoundedCornerBottomSheetDialogFragmen
|
||||
private const val KEY_PURPOSE = "purpose"
|
||||
|
||||
@JvmStatic
|
||||
fun show(context: Context, fragmentManager: FragmentManager, purpose: Purpose) {
|
||||
if (NetworkUtil.isConnected(context) && fragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||
fun show(activity: AppCompatActivity, purpose: Purpose) {
|
||||
if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (NetworkUtil.isConnected(activity) && activity.supportFragmentManager.findFragmentByTag(BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) == null) {
|
||||
DebugLogsPromptDialogFragment().apply {
|
||||
arguments = bundleOf(
|
||||
KEY_PURPOSE to purpose.serialized
|
||||
)
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}.show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
|
||||
when (purpose) {
|
||||
Purpose.NOTIFICATIONS -> SignalStore.uiHints().lastNotificationLogsPrompt = System.currentTimeMillis()
|
||||
|
||||
@@ -5,13 +5,11 @@ import android.graphics.PorterDuff;
|
||||
import android.graphics.PorterDuffColorFilter;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.style.CharacterStyle;
|
||||
import android.util.AttributeSet;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.BreakIteratorCompat;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
@@ -20,7 +18,6 @@ import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
|
||||
public class FromTextView extends SimpleEmojiTextView {
|
||||
|
||||
@@ -37,7 +37,6 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* LinearLayout that, when a view container, will report back when it thinks a soft keyboard
|
||||
|
||||
@@ -217,7 +217,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
thumbnailState.applyState(thumbnail);
|
||||
thumbnail.get().setImageResource(glideRequests, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION && !scheduleMessageMode, false);
|
||||
thumbnail.get().showDownloadText(false);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
} else if (callLinkRootKey != null) {
|
||||
thumbnail.setVisibility(VISIBLE);
|
||||
thumbnailState.applyState(thumbnail);
|
||||
@@ -228,7 +228,7 @@ public class LinkPreviewView extends FrameLayout {
|
||||
.asDrawable(getContext(),
|
||||
AvatarColorHash.forCallLink(callLinkRootKey.getKeyBytes()))
|
||||
);
|
||||
thumbnail.get().showDownloadText(false);
|
||||
thumbnail.get().showSecondaryText(false);
|
||||
} else {
|
||||
thumbnail.setVisibility(GONE);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.databinding.PromptBatterySaverBottomSheetBinding
|
||||
@@ -41,8 +40,6 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
|
||||
|
||||
private val binding by ViewBinderDelegate(PromptBatterySaverBottomSheetBinding::bind)
|
||||
|
||||
private lateinit var viewModel: PromptLogsViewModel
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
@@ -52,7 +49,6 @@ class PromptBatterySaverDialogFragment : FixedRoundedCornerBottomSheetDialogFrag
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
viewModel = ViewModelProvider(this)[PromptLogsViewModel::class.java]
|
||||
binding.continueButton.setOnClickListener {
|
||||
PowerManagerCompat.requestIgnoreBatteryOptimizations(requireContext())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.ContentUris;
|
||||
import android.content.Context;
|
||||
import android.database.Cursor;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.animation.LayoutTransition
|
||||
import android.view.View
|
||||
import android.view.View.OnAttachStateChangeListener
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
/**
|
||||
* Helps manage layout transition state inside a RecyclerView.
|
||||
*
|
||||
* Because of how RecyclerViews scroll, we need to be very careful about LayoutTransition
|
||||
* usage inside of them. This class helps wrap up the pattern of finding and listening to
|
||||
* the scroll events of a parent recycler view, so that we don't need to manually wire
|
||||
* the scroll state in everywhere.
|
||||
*/
|
||||
class RecyclerViewParentTransitionController(
|
||||
private val child: ViewGroup,
|
||||
private val transition: LayoutTransition = LayoutTransition()
|
||||
) : RecyclerView.OnScrollListener(), OnAttachStateChangeListener {
|
||||
|
||||
private var recyclerViewParent: RecyclerView? = null
|
||||
|
||||
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
|
||||
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
|
||||
child.layoutTransition = transition
|
||||
} else {
|
||||
child.layoutTransition = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewAttachedToWindow(v: View) {
|
||||
val parent = findRecyclerParent()
|
||||
|
||||
if (parent != null) {
|
||||
onScrollStateChanged(parent, parent.scrollState)
|
||||
}
|
||||
|
||||
parent?.addOnScrollListener(this)
|
||||
recyclerViewParent = parent
|
||||
}
|
||||
|
||||
override fun onViewDetachedFromWindow(v: View) {
|
||||
recyclerViewParent?.removeOnScrollListener(this)
|
||||
child.layoutTransition = null
|
||||
}
|
||||
|
||||
private fun findRecyclerParent(): RecyclerView? {
|
||||
var target: ViewGroup? = child.parent as? ViewGroup
|
||||
while (target != null) {
|
||||
if (target is RecyclerView) {
|
||||
return target
|
||||
}
|
||||
|
||||
target = target.parent as? ViewGroup
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
@@ -31,6 +36,7 @@ import org.signal.core.util.logging.Log;
|
||||
import org.signal.glide.transforms.SignalDownsampleStrategy;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.components.transfercontrols.TransferControlView;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
@@ -41,7 +47,6 @@ import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
@@ -50,6 +55,7 @@ import org.thoughtcrime.securesms.util.views.Stub;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
@@ -80,12 +86,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
|
||||
private final CornerMask cornerMask;
|
||||
|
||||
private ThumbnailViewTransferControlsState transferControlsState = new ThumbnailViewTransferControlsState();
|
||||
private Stub<TransferControlView> transferControlViewStub;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private SlideClickListener progressWheelClickListener = null;
|
||||
private Slide slide = null;
|
||||
private final Stub<TransferControlView> transferControlViewStub;
|
||||
private SlideClickListener thumbnailClickListener = null;
|
||||
private SlidesClickedListener downloadClickListener = null;
|
||||
private SlidesClickedListener cancelDownloadClickListener = null;
|
||||
private SlideClickListener playVideoClickListener = null;
|
||||
private Slide slide = null;
|
||||
|
||||
|
||||
public ThumbnailView(Context context) {
|
||||
@@ -278,15 +284,13 @@ public class ThumbnailView extends FrameLayout {
|
||||
@Override
|
||||
public void setFocusable(boolean focusable) {
|
||||
super.setFocusable(focusable);
|
||||
transferControlsState = transferControlsState.withFocusable(focusable);
|
||||
transferControlsState.applyState(transferControlViewStub);
|
||||
transferControlViewStub.get().setFocusable(focusable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClickable(boolean clickable) {
|
||||
super.setClickable(clickable);
|
||||
transferControlsState = transferControlsState.withClickable(clickable);
|
||||
transferControlsState.applyState(transferControlViewStub);
|
||||
transferControlViewStub.get().setClickable(clickable);
|
||||
}
|
||||
|
||||
public @Nullable Drawable getImageDrawable() {
|
||||
@@ -359,24 +363,15 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
int transferState = TransferControlView.getTransferState(Collections.singletonList(slide));
|
||||
if (transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
} else {
|
||||
transferControlViewStub.setVisibility(View.VISIBLE);
|
||||
transferControlViewStub.get().setDownloadClickListener(new DownloadClickDispatcher());
|
||||
transferControlViewStub.get().setCancelClickListener(new CancelClickDispatcher());
|
||||
if (MediaUtil.isInstantVideoSupported(slide)) {
|
||||
transferControlViewStub.get().setInstantPlaybackClickListener(new InstantVideoClickDispatcher());
|
||||
}
|
||||
|
||||
transferControlsState = transferControlsState.withSlide(slide)
|
||||
.withDownloadClickListener(new DownloadClickDispatcher());
|
||||
|
||||
if (FeatureFlags.instantVideoPlayback()) {
|
||||
transferControlsState = transferControlsState.withProgressWheelClickListener(new ProgressWheelClickDispatcher());
|
||||
}
|
||||
|
||||
transferControlsState.applyState(transferControlViewStub);
|
||||
} else {
|
||||
transferControlViewStub.setVisibility(View.GONE);
|
||||
transferControlViewStub.get().setSlides(List.of(slide));
|
||||
}
|
||||
int transferState = TransferControlView.getTransferState(List.of(slide));
|
||||
transferControlViewStub.get().setVisible(showControls && transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE);
|
||||
|
||||
if (slide.getUri() != null && slide.hasPlayOverlay() &&
|
||||
(slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE || isPreview))
|
||||
@@ -525,8 +520,41 @@ public class ThumbnailView extends FrameLayout {
|
||||
this.downloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setProgressWheelClickListener(SlideClickListener listener) {
|
||||
this.progressWheelClickListener = listener;
|
||||
public void setCancelDownloadClickListener(SlidesClickedListener listener) {
|
||||
this.cancelDownloadClickListener = listener;
|
||||
}
|
||||
|
||||
public void setPlayVideoClickListener(SlideClickListener listener) {
|
||||
this.playVideoClickListener = listener;
|
||||
}
|
||||
|
||||
private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) {
|
||||
if (Util.equals(slide, other)) {
|
||||
|
||||
if (slide != null && other != null) {
|
||||
byte[] digestLeft = slide.asAttachment().getDigest();
|
||||
byte[] digestRight = other.asAttachment().getDigest();
|
||||
|
||||
return Arrays.equals(digestLeft, digestRight);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private GlideRequest<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
GlideRequest<Drawable> request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
|
||||
.transition(withCrossFade()));
|
||||
|
||||
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
|
||||
|
||||
if (slide.isInProgress() || doNotShowMissingThumbnailImage) {
|
||||
return request;
|
||||
} else {
|
||||
return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
|
||||
}
|
||||
}
|
||||
|
||||
public void clear(GlideRequests glideRequests) {
|
||||
@@ -543,13 +571,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
slide = null;
|
||||
}
|
||||
|
||||
public void showDownloadText(boolean showDownloadText) {
|
||||
transferControlsState = transferControlsState.withDownloadText(showDownloadText);
|
||||
transferControlsState.applyState(transferControlViewStub);
|
||||
public void showSecondaryText(boolean showSecondaryText) {
|
||||
transferControlViewStub.get().setShowSecondaryText(showSecondaryText);
|
||||
}
|
||||
|
||||
public void showProgressSpinner() {
|
||||
transferControlViewStub.get().showProgressSpinner();
|
||||
transferControlViewStub.get().setVisible(true);
|
||||
}
|
||||
|
||||
public void setScaleType(@NonNull ImageView.ScaleType scaleType) {
|
||||
@@ -566,20 +593,6 @@ public class ThumbnailView extends FrameLayout {
|
||||
invalidate();
|
||||
}
|
||||
|
||||
private GlideRequest<Drawable> buildThumbnailGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
GlideRequest<Drawable> request = applySizing(glideRequests.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE)
|
||||
.transition(withCrossFade()));
|
||||
|
||||
boolean doNotShowMissingThumbnailImage = Build.VERSION.SDK_INT < 23;
|
||||
|
||||
if (slide.isInProgress() || doNotShowMissingThumbnailImage) {
|
||||
return request;
|
||||
} else {
|
||||
return request.apply(RequestOptions.errorOf(R.drawable.ic_missing_thumbnail_picture));
|
||||
}
|
||||
}
|
||||
|
||||
private RequestBuilder<Bitmap> buildPlaceholderGlideRequest(@NonNull GlideRequests glideRequests, @NonNull Slide slide) {
|
||||
GlideRequest<Bitmap> bitmap = glideRequests.asBitmap();
|
||||
@@ -591,7 +604,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme()));
|
||||
}
|
||||
|
||||
return applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
|
||||
final GlideRequest<Bitmap> resizedRequest = applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
|
||||
if (placeholderBlur != null) {
|
||||
return resizedRequest.centerCrop();
|
||||
} else {
|
||||
return resizedRequest;
|
||||
}
|
||||
}
|
||||
|
||||
private <TranscodeType> GlideRequest<TranscodeType> applySizing(@NonNull GlideRequest<TranscodeType> request) {
|
||||
@@ -621,19 +639,6 @@ public class ThumbnailView extends FrameLayout {
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static boolean hasSameContents(@Nullable Slide slide, @Nullable Slide other) {
|
||||
if (Util.equals(slide, other)) {
|
||||
|
||||
if (slide != null && other != null) {
|
||||
byte[] digestLeft = slide.asAttachment().getDigest();
|
||||
byte[] digestRight = other.asAttachment().getDigest();
|
||||
|
||||
return Arrays.equals(digestLeft, digestRight);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public interface ThumbnailRequestListener extends RequestListener<Drawable> {
|
||||
void onLoadCanceled();
|
||||
@@ -670,14 +675,26 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
}
|
||||
|
||||
private class ProgressWheelClickDispatcher implements View.OnClickListener {
|
||||
private class CancelClickDispatcher implements View.OnClickListener {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.i(TAG, "onClick() for progress wheel");
|
||||
if (progressWheelClickListener != null && slide != null) {
|
||||
progressWheelClickListener.onClick(view, slide);
|
||||
Log.i(TAG, "onClick() for cancel button");
|
||||
if (cancelDownloadClickListener != null && slide != null) {
|
||||
cancelDownloadClickListener.onClick(view, Collections.singletonList(slide));
|
||||
} else {
|
||||
Log.w(TAG, "Received a progress wheel click, but unable to execute it. slide: " + slide + " progressWheelClickListener: " + progressWheelClickListener);
|
||||
Log.w(TAG, "Received a cancel button click, but unable to execute it. slide: " + slide + " cancelDownloadClickListener: " + cancelDownloadClickListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class InstantVideoClickDispatcher implements View.OnClickListener {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
Log.i(TAG, "onClick() for instant video playback");
|
||||
if (playVideoClickListener != null && slide != null) {
|
||||
playVideoClickListener.onClick(view, slide);
|
||||
} else {
|
||||
Log.w(TAG, "Received an instant video click, but unable to execute it. slide: " + slide + " playVideoClickListener: " + playVideoClickListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.view.View.OnClickListener
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
|
||||
/**
|
||||
* State object for transfer controls.
|
||||
*/
|
||||
data class ThumbnailViewTransferControlsState(
|
||||
val isFocusable: Boolean = true,
|
||||
val isClickable: Boolean = true,
|
||||
val slide: Slide? = null,
|
||||
val downloadClickedListener: OnClickListener? = null,
|
||||
val progressWheelClickedListener: OnClickListener? = null,
|
||||
val showDownloadText: Boolean = true
|
||||
) {
|
||||
|
||||
fun withFocusable(isFocusable: Boolean): ThumbnailViewTransferControlsState = copy(isFocusable = isFocusable)
|
||||
fun withClickable(isClickable: Boolean): ThumbnailViewTransferControlsState = copy(isClickable = isClickable)
|
||||
fun withSlide(slide: Slide?): ThumbnailViewTransferControlsState = copy(slide = slide)
|
||||
fun withDownloadClickListener(downloadClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(downloadClickedListener = downloadClickedListener)
|
||||
fun withProgressWheelClickListener(progressWheelClickedListener: OnClickListener): ThumbnailViewTransferControlsState = copy(progressWheelClickedListener = progressWheelClickedListener)
|
||||
fun withDownloadText(showDownloadText: Boolean): ThumbnailViewTransferControlsState = copy(showDownloadText = showDownloadText)
|
||||
|
||||
fun applyState(transferControlView: Stub<TransferControlView>) {
|
||||
if (transferControlView.resolved()) {
|
||||
transferControlView.get().isFocusable = isFocusable
|
||||
transferControlView.get().isClickable = isClickable
|
||||
if (slide != null) {
|
||||
transferControlView.get().setSlide(slide)
|
||||
}
|
||||
transferControlView.get().setDownloadClickListener(downloadClickedListener)
|
||||
transferControlView.get().setProgressWheelClickListener(progressWheelClickedListener)
|
||||
transferControlView.get().setShowDownloadText(showDownloadText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Build;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.animation.LayoutTransition;
|
||||
import android.content.Context;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.events.PartProgressEvent;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class TransferControlView extends FrameLayout {
|
||||
|
||||
private static final String TAG = "TransferControlView";
|
||||
private static final int UPLOAD_TASK_WEIGHT = 1;
|
||||
|
||||
/**
|
||||
* A weighting compared to {@link #UPLOAD_TASK_WEIGHT}
|
||||
*/
|
||||
private static final int COMPRESSION_TASK_WEIGHT = 3;
|
||||
|
||||
@Nullable private List<Slide> slides;
|
||||
@Nullable private View current;
|
||||
|
||||
private final ProgressWheel progressWheel;
|
||||
private final View downloadDetails;
|
||||
private final TextView downloadDetailsText;
|
||||
|
||||
private final Map<Attachment, Float> networkProgress;
|
||||
private final Map<Attachment, Float> compresssionProgress;
|
||||
|
||||
public TransferControlView(Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public TransferControlView(Context context, AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public TransferControlView(Context context, AttributeSet attrs, int defStyleAttr) {
|
||||
super(context, attrs, defStyleAttr);
|
||||
inflate(context, R.layout.transfer_controls_view, this);
|
||||
|
||||
setLongClickable(false);
|
||||
setBackground(ContextCompat.getDrawable(context, R.drawable.transfer_controls_background));
|
||||
setVisibility(GONE);
|
||||
setLayoutTransition(new LayoutTransition());
|
||||
|
||||
this.networkProgress = new HashMap<>();
|
||||
this.compresssionProgress = new HashMap<>();
|
||||
|
||||
this.progressWheel = findViewById(R.id.progress_wheel);
|
||||
this.downloadDetails = findViewById(R.id.download_details);
|
||||
this.downloadDetailsText = findViewById(R.id.download_details_text);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFocusable(boolean focusable) {
|
||||
super.setFocusable(focusable);
|
||||
downloadDetails.setFocusable(focusable);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClickable(boolean clickable) {
|
||||
super.setClickable(clickable);
|
||||
downloadDetails.setClickable(clickable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
if (!EventBus.getDefault().isRegistered(this)) EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
public void setSlide(final @NonNull Slide slides) {
|
||||
setSlides(Collections.singletonList(slides));
|
||||
}
|
||||
|
||||
public void setSlides(final @NonNull List<Slide> slides) {
|
||||
if (slides.isEmpty()) {
|
||||
throw new IllegalArgumentException("Must provide at least one slide.");
|
||||
}
|
||||
|
||||
this.slides = slides;
|
||||
|
||||
if (!isUpdateToExistingSet(slides)) {
|
||||
networkProgress.clear();
|
||||
compresssionProgress.clear();
|
||||
Stream.of(slides).forEach(s -> networkProgress.put(s.asAttachment(), 0f));
|
||||
}
|
||||
|
||||
for (Slide slide : slides) {
|
||||
if (slide.asAttachment().getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
networkProgress.put(slide.asAttachment(), 1f);
|
||||
}
|
||||
}
|
||||
|
||||
switch (getTransferState(slides)) {
|
||||
case AttachmentTable.TRANSFER_PROGRESS_STARTED:
|
||||
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
|
||||
break;
|
||||
case AttachmentTable.TRANSFER_PROGRESS_PENDING:
|
||||
case AttachmentTable.TRANSFER_PROGRESS_FAILED:
|
||||
String downloadText = getDownloadText(this.slides);
|
||||
if (!Objects.equals(downloadText, downloadDetailsText.getText().toString())) {
|
||||
downloadDetailsText.setText(getDownloadText(this.slides));
|
||||
}
|
||||
|
||||
display(downloadDetails);
|
||||
break;
|
||||
default:
|
||||
display(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void showProgressSpinner() {
|
||||
showProgressSpinner(calculateProgress(networkProgress, compresssionProgress));
|
||||
}
|
||||
|
||||
public void showProgressSpinner(float progress) {
|
||||
if (progress == 0) {
|
||||
progressWheel.spin();
|
||||
} else {
|
||||
progressWheel.setInstantProgress(progress);
|
||||
}
|
||||
|
||||
display(progressWheel);
|
||||
}
|
||||
|
||||
public void setDownloadClickListener(final @Nullable OnClickListener listener) {
|
||||
downloadDetails.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void setProgressWheelClickListener(final @Nullable OnClickListener listener) {
|
||||
progressWheel.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
clearAnimation();
|
||||
setVisibility(GONE);
|
||||
if (current != null) {
|
||||
current.clearAnimation();
|
||||
current.setVisibility(GONE);
|
||||
}
|
||||
current = null;
|
||||
slides = null;
|
||||
}
|
||||
|
||||
public void setShowDownloadText(boolean showDownloadText) {
|
||||
downloadDetailsText.setVisibility(showDownloadText ? VISIBLE : GONE);
|
||||
forceLayout();
|
||||
}
|
||||
|
||||
private boolean isUpdateToExistingSet(@NonNull List<Slide> slides) {
|
||||
if (slides.size() != networkProgress.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (Slide slide : slides) {
|
||||
if (!networkProgress.containsKey(slide.asAttachment())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static int getTransferState(@NonNull List<Slide> slides) {
|
||||
int transferState = AttachmentTable.TRANSFER_PROGRESS_DONE;
|
||||
boolean allFailed = true;
|
||||
|
||||
for (Slide slide : slides) {
|
||||
if (slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
|
||||
allFailed = false;
|
||||
if (slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_PENDING && transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) {
|
||||
transferState = slide.getTransferState();
|
||||
} else {
|
||||
transferState = Math.max(transferState, slide.getTransferState());
|
||||
}
|
||||
}
|
||||
}
|
||||
return allFailed ? AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE : transferState;
|
||||
}
|
||||
|
||||
private String getDownloadText(@NonNull List<Slide> slides) {
|
||||
if (slides.size() == 1) {
|
||||
return slides.get(0).getContentDescription(getContext());
|
||||
} else {
|
||||
int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE ? count + 1 : count);
|
||||
return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void display(@Nullable final View view) {
|
||||
if (current == view) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current != null) {
|
||||
current.setVisibility(GONE);
|
||||
}
|
||||
|
||||
if (view != null) {
|
||||
view.setVisibility(VISIBLE);
|
||||
setVisibility(VISIBLE);
|
||||
} else {
|
||||
setVisibility(GONE);
|
||||
}
|
||||
|
||||
current = view;
|
||||
}
|
||||
|
||||
private static float calculateProgress(@NonNull Map<Attachment, Float> uploadDownloadProgress, Map<Attachment, Float> compresssionProgress) {
|
||||
float totalDownloadProgress = 0;
|
||||
float totalCompressionProgress = 0;
|
||||
|
||||
for (float progress : uploadDownloadProgress.values()) {
|
||||
totalDownloadProgress += progress;
|
||||
}
|
||||
|
||||
for (float progress : compresssionProgress.values()) {
|
||||
totalCompressionProgress += progress;
|
||||
}
|
||||
|
||||
float weightedProgress = UPLOAD_TASK_WEIGHT * totalDownloadProgress + COMPRESSION_TASK_WEIGHT * totalCompressionProgress;
|
||||
float weightedTotal = UPLOAD_TASK_WEIGHT * uploadDownloadProgress.size() + COMPRESSION_TASK_WEIGHT * compresssionProgress.size();
|
||||
|
||||
return weightedProgress / weightedTotal;
|
||||
}
|
||||
|
||||
@Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
|
||||
public void onEventAsync(final PartProgressEvent event) {
|
||||
final Attachment attachment = event.attachment;
|
||||
if (networkProgress.containsKey(attachment)) {
|
||||
float proportionCompleted = ((float) event.progress) / event.total;
|
||||
|
||||
if (event.type == PartProgressEvent.Type.COMPRESSION) {
|
||||
compresssionProgress.put(attachment, proportionCompleted);
|
||||
} else {
|
||||
networkProgress.put(attachment, proportionCompleted);
|
||||
}
|
||||
|
||||
progressWheel.setInstantProgress(calculateProgress(networkProgress, compresssionProgress));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.net.Uri;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.MotionEvent;
|
||||
@@ -10,13 +9,9 @@ import android.view.View;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.exifinterface.media.ExifInterface;
|
||||
|
||||
import com.bumptech.glide.load.DataSource;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.engine.GlideException;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.target.Target;
|
||||
import com.davemorrissey.labs.subscaleview.ImageSource;
|
||||
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView;
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
@@ -17,7 +18,6 @@ import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PendingChangeNumberMetadata
|
||||
import org.thoughtcrime.securesms.database.model.toProtoByteString
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.RefreshAttributesJob
|
||||
import org.thoughtcrime.securesms.keyvalue.CertificateType
|
||||
@@ -42,7 +42,7 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.push.KyberPreKeyEntity
|
||||
import org.whispersystems.signalservice.internal.push.OutgoingPushMessage
|
||||
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage
|
||||
import org.whispersystems.signalservice.internal.push.SyncMessage
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException
|
||||
@@ -367,13 +367,13 @@ class ChangeNumberRepository(
|
||||
|
||||
// Device Messages
|
||||
if (deviceId != primaryDeviceId) {
|
||||
val pniChangeNumber = SyncMessage.PniChangeNumber.newBuilder()
|
||||
.setIdentityKeyPair(pniIdentity.serialize().toProtoByteString())
|
||||
.setSignedPreKey(signedPreKeyRecord.serialize().toProtoByteString())
|
||||
.setLastResortKyberPreKey(lastResortKyberPreKeyRecord.serialize().toProtoByteString())
|
||||
.setRegistrationId(pniRegistrationId)
|
||||
.setNewE164(newE164)
|
||||
.build()
|
||||
val pniChangeNumber = SyncMessage.PniChangeNumber(
|
||||
identityKeyPair = pniIdentity.serialize().toByteString(),
|
||||
signedPreKey = signedPreKeyRecord.serialize().toByteString(),
|
||||
lastResortKyberPreKey = lastResortKyberPreKeyRecord.serialize().toByteString(),
|
||||
registrationId = pniRegistrationId,
|
||||
newE164 = newE164
|
||||
)
|
||||
|
||||
deviceMessages += messageSender.getEncryptedSyncPniInitializeDeviceMessage(deviceId, pniChangeNumber)
|
||||
}
|
||||
@@ -391,12 +391,12 @@ class ChangeNumberRepository(
|
||||
pniRegistrationIds.mapKeys { it.key.toString() }
|
||||
)
|
||||
|
||||
val metadata = PendingChangeNumberMetadata.newBuilder()
|
||||
.setPreviousPni(SignalStore.account().pni!!.toByteString())
|
||||
.setPniIdentityKeyPair(pniIdentity.serialize().toProtoByteString())
|
||||
.setPniRegistrationId(pniRegistrationIds[primaryDeviceId]!!)
|
||||
.setPniSignedPreKeyId(devicePniSignedPreKeys[primaryDeviceId]!!.keyId)
|
||||
.build()
|
||||
val metadata = PendingChangeNumberMetadata(
|
||||
previousPni = SignalStore.account().pni!!.toByteString(),
|
||||
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
|
||||
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
|
||||
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId
|
||||
)
|
||||
|
||||
return ChangeNumberRequestData(request, metadata)
|
||||
}
|
||||
|
||||
@@ -725,7 +725,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionRedemption() {
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue()
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(-1L).enqueue()
|
||||
}
|
||||
|
||||
private fun enqueueSubscriptionKeepAlive() {
|
||||
|
||||
@@ -32,7 +32,7 @@ class InternalSettingsRepository(context: Context) {
|
||||
val title = "Release Note Title"
|
||||
val bodyText = "Release note body. Aren't I awesome?"
|
||||
val body = "$title\n\n$bodyText"
|
||||
val bodyRangeList = BodyRangeList.newBuilder()
|
||||
val bodyRangeList = BodyRangeList.Builder()
|
||||
.addStyle(BodyRangeList.BodyRange.Style.BOLD, 0, title.length)
|
||||
|
||||
val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!!
|
||||
|
||||
@@ -8,17 +8,20 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.internal.search
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -52,15 +55,21 @@ class InternalSearchFragment : ComposeFragment() {
|
||||
|
||||
@Composable
|
||||
fun InternalSearchFragmentScreen(query: String, results: ImmutableList<InternalSearchResult>, onSearchUpdated: (String) -> Unit, modifier: Modifier = Modifier) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth()
|
||||
) {
|
||||
item(key = -1) {
|
||||
SearchBar(query, onSearchUpdated)
|
||||
}
|
||||
results.forEach { recipient ->
|
||||
item(key = recipient.id) {
|
||||
ResultItem(recipient)
|
||||
val backgroundColor = MaterialTheme.colorScheme.surface
|
||||
|
||||
CompositionLocalProvider(LocalContentColor provides contentColorFor(backgroundColor = backgroundColor)) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor)
|
||||
) {
|
||||
item(key = -1) {
|
||||
SearchBar(query, onSearchUpdated)
|
||||
}
|
||||
results.forEach { recipient ->
|
||||
item(key = recipient.id) {
|
||||
ResultItem(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,7 +94,9 @@ fun ResultItem(result: InternalSearchResult, modifier: Modifier = Modifier) {
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
if (activity != null) {
|
||||
RecipientBottomSheetDialogFragment.create(result.id, result.groupId).show(activity.supportFragmentManager, "TAG")
|
||||
RecipientBottomSheetDialogFragment
|
||||
.create(result.id, result.groupId)
|
||||
.show(activity.supportFragmentManager, "TAG")
|
||||
}
|
||||
}
|
||||
.padding(8.dp)
|
||||
@@ -101,9 +112,7 @@ fun ResultItem(result: InternalSearchResult, modifier: Modifier = Modifier) {
|
||||
@Composable
|
||||
fun InternalSearchScreenPreviewLightTheme() {
|
||||
SignalTheme(isDarkMode = false) {
|
||||
Surface {
|
||||
InternalSearchScreenPreview()
|
||||
}
|
||||
InternalSearchScreenPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,9 +120,7 @@ fun InternalSearchScreenPreviewLightTheme() {
|
||||
@Composable
|
||||
fun InternalSearchScreenPreviewDarkTheme() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface {
|
||||
InternalSearchScreenPreview()
|
||||
}
|
||||
InternalSearchScreenPreview()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import java.util.Currency
|
||||
|
||||
private const val CARD = "CARD"
|
||||
private const val PAYPAL = "PAYPAL"
|
||||
private const val SEPA_DEBIT = "SEPA_DEBIT"
|
||||
|
||||
/**
|
||||
* Transforms the DonationsConfiguration into a Set<FiatMoney> which has been properly filtered
|
||||
@@ -116,6 +117,7 @@ private fun DonationsConfiguration.getFilteredCurrencies(paymentMethodAvailabili
|
||||
interface PaymentMethodAvailability {
|
||||
fun isPayPalAvailable(): Boolean
|
||||
fun isGooglePayOrCreditCardAvailable(): Boolean
|
||||
fun isSEPADebitAvailable(): Boolean
|
||||
|
||||
fun toSet(): Set<String> {
|
||||
val set = mutableSetOf<String>()
|
||||
@@ -127,6 +129,10 @@ interface PaymentMethodAvailability {
|
||||
set.add(CARD)
|
||||
}
|
||||
|
||||
if (isSEPADebitAvailable()) {
|
||||
set.add(SEPA_DEBIT)
|
||||
}
|
||||
|
||||
return set
|
||||
}
|
||||
}
|
||||
@@ -134,4 +140,5 @@ interface PaymentMethodAvailability {
|
||||
private object DefaultPaymentMethodAvailability : PaymentMethodAvailability {
|
||||
override fun isPayPalAvailable(): Boolean = InAppDonations.isPayPalAvailable()
|
||||
override fun isGooglePayOrCreditCardAvailable(): Boolean = InAppDonations.isCreditCardAvailable() || InAppDonations.isGooglePayAvailable()
|
||||
override fun isSEPADebitAvailable(): Boolean = InAppDonations.isSEPADebitAvailable()
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ object InAppDonations {
|
||||
*
|
||||
* - Able to use Credit Cards and is in a region where they are able to be accepted.
|
||||
* - Able to access Google Play services (and thus possibly able to use Google Pay).
|
||||
* - Able to use SEPA Debit and is in a region where they are able to be accepted.
|
||||
* - Able to use PayPal and is in a region where it is able to be accepted.
|
||||
*/
|
||||
fun hasAtLeastOnePaymentMethodAvailable(): Boolean {
|
||||
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable()
|
||||
return isCreditCardAvailable() || isPayPalAvailable() || isGooglePayAvailable() || isSEPADebitAvailable()
|
||||
}
|
||||
|
||||
fun isPaymentSourceAvailable(paymentSourceType: PaymentSourceType, donateToSignalType: DonateToSignalType): Boolean {
|
||||
@@ -27,6 +28,7 @@ object InAppDonations {
|
||||
PaymentSourceType.PayPal -> isPayPalAvailableForDonateToSignalType(donateToSignalType)
|
||||
PaymentSourceType.Stripe.CreditCard -> isCreditCardAvailable()
|
||||
PaymentSourceType.Stripe.GooglePay -> isGooglePayAvailable()
|
||||
PaymentSourceType.Stripe.SEPADebit -> isSEPADebitAvailable()
|
||||
PaymentSourceType.Unknown -> false
|
||||
}
|
||||
}
|
||||
@@ -58,4 +60,11 @@ object InAppDonations {
|
||||
fun isGooglePayAvailable(): Boolean {
|
||||
return SignalStore.donationsValues().isGooglePayReady && !LocaleFeatureFlags.isGooglePayDisabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user is in a region which supports SEPA Debit transfers, based off local phone number.
|
||||
*/
|
||||
fun isSEPADebitAvailable(): Boolean {
|
||||
return FeatureFlags.sepaDebitDonations()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,29 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(): Completable {
|
||||
Log.d(TAG, "Ensuring SubscriberId exists on Signal service...", true)
|
||||
val subscriberId = SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
/**
|
||||
* Since PayPal and Stripe can't interoperate, we need to be able to rotate the subscriber ID
|
||||
* in case of failures.
|
||||
*/
|
||||
fun rotateSubscriberId(): Completable {
|
||||
Log.d(TAG, "Rotating SubscriberId due to alternate payment processor...", true)
|
||||
val cancelCompletable: Completable = if (SignalStore.donationsValues().getSubscriber() != null) {
|
||||
cancelActiveSubscription().andThen(updateLocalSubscriptionStateAndScheduleDataSync())
|
||||
} else {
|
||||
Completable.complete()
|
||||
}
|
||||
|
||||
return cancelCompletable.andThen(ensureSubscriberId(isRotation = true))
|
||||
}
|
||||
|
||||
fun ensureSubscriberId(isRotation: Boolean = false): Completable {
|
||||
Log.d(TAG, "Ensuring SubscriberId exists on Signal service {isRotation?$isRotation}...", true)
|
||||
val subscriberId: SubscriberId = if (isRotation) {
|
||||
SubscriberId.generate()
|
||||
} else {
|
||||
SignalStore.donationsValues().getSubscriber()?.subscriberId ?: SubscriberId.generate()
|
||||
}
|
||||
|
||||
return Single
|
||||
.fromCallable {
|
||||
donationsService.putSubscription(subscriberId)
|
||||
@@ -127,7 +147,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSubscriptionLevel(subscriptionLevel: String): Completable {
|
||||
fun setSubscriptionLevel(subscriptionLevel: String, uiSessionKey: Long): Completable {
|
||||
return getOrCreateLevelUpdateOperation(subscriptionLevel)
|
||||
.flatMapCompletable { levelUpdateOperation ->
|
||||
val subscriber = SignalStore.donationsValues().requireSubscriber()
|
||||
@@ -166,7 +186,7 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain().enqueue { _, jobState ->
|
||||
SubscriptionReceiptRequestResponseJob.createSubscriptionContinuationJobChain(uiSessionKey).enqueue { _, jobState ->
|
||||
if (jobState.isComplete) {
|
||||
finalJobState = jobState
|
||||
countDownLatch.countDown()
|
||||
@@ -222,4 +242,18 @@ class MonthlyDonationRepository(private val donationsService: DonationsService)
|
||||
levelUpdateOperation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local state information and schedule a storage sync for the change. This method
|
||||
* assumes you've already properly called the DELETE method for the stored ID on the server.
|
||||
*/
|
||||
private fun updateLocalSubscriptionStateAndScheduleDataSync(): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Marking subscription cancelled...", true)
|
||||
SignalStore.donationsValues().updateLocalStateForManualCancellation()
|
||||
MultiDeviceSubscriptionSyncRequestJob.enqueue()
|
||||
SignalDatabase.recipients.markNeedsSync(Recipient.self().id)
|
||||
StorageSyncHelper.scheduleSyncForDataChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,8 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
badgeRecipient: RecipientId,
|
||||
additionalMessage: String?,
|
||||
badgeLevel: Long,
|
||||
donationProcessor: DonationProcessor
|
||||
donationProcessor: DonationProcessor,
|
||||
uiSessionKey: Long
|
||||
): Completable {
|
||||
val isBoost = badgeRecipient == Recipient.self().id
|
||||
val donationErrorSource: DonationErrorSource = if (isBoost) DonationErrorSource.BOOST else DonationErrorSource.GIFT
|
||||
@@ -131,9 +132,9 @@ class OneTimeDonationRepository(private val donationsService: DonationsService)
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val chain = if (isBoost) {
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor)
|
||||
BoostReceiptRequestResponseJob.createJobChainForBoost(paymentIntentId, donationProcessor, uiSessionKey)
|
||||
} else {
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor)
|
||||
BoostReceiptRequestResponseJob.createJobChainForGift(paymentIntentId, badgeRecipient, additionalMessage, badgeLevel, donationProcessor, uiSessionKey)
|
||||
}
|
||||
|
||||
chain.enqueue { _, jobState ->
|
||||
|
||||
@@ -29,6 +29,8 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
private val TAG = Log.tag(PayPalRepository::class.java)
|
||||
}
|
||||
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(donationsService)
|
||||
|
||||
fun createOneTimePaymentIntent(
|
||||
amount: FiatMoney,
|
||||
badgeRecipient: RecipientId,
|
||||
@@ -69,7 +71,12 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createPaymentMethod(): Single<PayPalCreatePaymentMethodResponse> {
|
||||
/**
|
||||
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
|
||||
* it means that the PaymentMethod is already tied to a Stripe account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
fun createPaymentMethod(retryOn409: Boolean = true): Single<PayPalCreatePaymentMethodResponse> {
|
||||
return Single.fromCallable {
|
||||
donationsService.createPayPalPaymentMethod(
|
||||
Locale.getDefault(),
|
||||
@@ -77,7 +84,13 @@ class PayPalRepository(private val donationsService: DonationsService) {
|
||||
MONTHLY_RETURN_URL,
|
||||
CANCEL_URL
|
||||
)
|
||||
}.flatMap { it.flattenResult() }.subscribeOn(Schedulers.io())
|
||||
}.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun setDefaultPaymentMethod(paymentMethodId: String): Completable {
|
||||
|
||||
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.errors.Do
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.StandardUserAgentInterceptor
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||
@@ -46,7 +47,8 @@ import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, StripeApi.SetupIntentHelper {
|
||||
|
||||
private val googlePayApi = GooglePayApi(activity, StripeApi.Gateway(Environment.Donations.STRIPE_CONFIGURATION), Environment.Donations.GOOGLE_PAY_CONFIGURATION)
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient())
|
||||
private val stripeApi = StripeApi(Environment.Donations.STRIPE_CONFIGURATION, this, this, ApplicationDependencies.getOkHttpClient(), StandardUserAgentInterceptor.USER_AGENT)
|
||||
private val monthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
|
||||
fun isGooglePayAvailable(): Completable {
|
||||
return googlePayApi.queryIsReadyToPay()
|
||||
@@ -89,9 +91,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
badgeLevel: Long,
|
||||
paymentSourceType: PaymentSourceType
|
||||
): Single<StripeIntentAccessor> {
|
||||
check(paymentSourceType is PaymentSourceType.Stripe)
|
||||
|
||||
Log.d(TAG, "Creating payment intent for $price...", true)
|
||||
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel)
|
||||
return stripeApi.createPaymentIntent(price, badgeLevel, paymentSourceType)
|
||||
.onErrorResumeNext {
|
||||
OneTimeDonationRepository.handleCreatePaymentIntentError(it, badgeRecipient, paymentSourceType)
|
||||
}
|
||||
@@ -109,9 +113,12 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createAndConfirmSetupIntent(paymentSource: StripeApi.PaymentSource): Single<StripeApi.Secure3DSAction> {
|
||||
fun createAndConfirmSetupIntent(
|
||||
paymentSource: StripeApi.PaymentSource,
|
||||
paymentSourceType: PaymentSourceType.Stripe
|
||||
): Single<StripeApi.Secure3DSAction> {
|
||||
Log.d(TAG, "Continuing subscription setup...", true)
|
||||
return stripeApi.createSetupIntent()
|
||||
return stripeApi.createSetupIntent(paymentSourceType)
|
||||
.flatMap { result ->
|
||||
Log.d(TAG, "Retrieved SetupIntent, confirming...", true)
|
||||
stripeApi.confirmSetupIntent(paymentSource, result.setupIntent)
|
||||
@@ -133,13 +140,13 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long): Single<StripeIntentAccessor> {
|
||||
override fun fetchPaymentIntent(price: FiatMoney, level: Long, sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching payment intent from Signal service for $price... (Locale.US minimum precision: ${price.minimumUnitPrecisionString})")
|
||||
return Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level)
|
||||
.createDonationIntentWithAmount(price.minimumUnitPrecisionString, price.currency.currencyCode, level, sourceType.paymentMethod)
|
||||
}
|
||||
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
|
||||
.map {
|
||||
@@ -153,17 +160,32 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching setup intent from Signal service...")
|
||||
/**
|
||||
* Creates the PaymentMethod via the Signal Service. Note that if the operation fails with a 409,
|
||||
* it means that the PaymentMethod is already tied to a PayPal account. We can retry in this
|
||||
* situation by simply deleting the old subscriber id on the service and replacing it.
|
||||
*/
|
||||
private fun createPaymentMethod(paymentSourceType: PaymentSourceType.Stripe, retryOn409: Boolean = true): Single<StripeClientSecret> {
|
||||
return Single.fromCallable { SignalStore.donationsValues().requireSubscriber() }
|
||||
.flatMap {
|
||||
Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.createStripeSubscriptionPaymentMethod(it.subscriberId)
|
||||
.createStripeSubscriptionPaymentMethod(it.subscriberId, paymentSourceType.paymentMethod)
|
||||
}
|
||||
}
|
||||
.flatMap(ServiceResponse<StripeClientSecret>::flattenResult)
|
||||
.flatMap { serviceResponse ->
|
||||
if (retryOn409 && serviceResponse.status == 409) {
|
||||
monthlyDonationRepository.rotateSubscriberId().andThen(createPaymentMethod(paymentSourceType, retryOn409 = false))
|
||||
} else {
|
||||
serviceResponse.flattenResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchSetupIntent(sourceType: PaymentSourceType.Stripe): Single<StripeIntentAccessor> {
|
||||
Log.d(TAG, "Fetching setup intent from Signal service...")
|
||||
return createPaymentMethod(sourceType)
|
||||
.map {
|
||||
StripeIntentAccessor(
|
||||
objectType = StripeIntentAccessor.ObjectType.SETUP_INTENT,
|
||||
@@ -191,6 +213,7 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
StatusAndPaymentMethodId(it.status ?: StripeIntentStatus.SUCCEEDED, it.paymentMethod)
|
||||
}
|
||||
|
||||
StripeIntentAccessor.ObjectType.SETUP_INTENT -> stripeApi.getSetupIntent(stripeIntentAccessor).let {
|
||||
StatusAndPaymentMethodId(it.status, it.paymentMethod)
|
||||
}
|
||||
@@ -229,6 +252,11 @@ class StripeRepository(activity: Activity) : StripeApi.PaymentIntentFetcher, Str
|
||||
}
|
||||
}
|
||||
|
||||
fun createSEPADebitPaymentSource(sepaDebitData: StripeApi.SEPADebitData): Single<StripeApi.PaymentSource> {
|
||||
Log.d(TAG, "Creating SEPA Debit payment source via Stripe api...")
|
||||
return stripeApi.createPaymentSourceFromSEPADebitData(sepaDebitData)
|
||||
}
|
||||
|
||||
data class StatusAndPaymentMethodId(
|
||||
val status: StripeIntentStatus,
|
||||
val paymentMethod: String?
|
||||
|
||||
@@ -106,7 +106,7 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
|
||||
donationCheckoutDelegate = DonationCheckoutDelegate(this, this, viewModel.uiSessionKey, DonationErrorSource.BOOST, DonationErrorSource.SUBSCRIPTION)
|
||||
|
||||
val recyclerView = this.recyclerView!!
|
||||
recyclerView.overScrollMode = RecyclerView.OVER_SCROLL_IF_CONTENT_SCROLLS
|
||||
@@ -417,6 +417,10 @@ class DonateToSignalFragment :
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToCreditCardFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToBankTransferMandateFragment(gatewayRequest))
|
||||
}
|
||||
|
||||
override fun onPaymentComplete(gatewayRequest: GatewayRequest) {
|
||||
findNavController().safeNavigate(DonateToSignalFragmentDirections.actionDonateToSignalFragmentToThanksForYourSupportBottomSheetDialog(gatewayRequest.badge))
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ class DonateToSignalViewModel(
|
||||
|
||||
val state = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
val actions: Observable<DonateToSignalAction> = _actions.observeOn(AndroidSchedulers.mainThread())
|
||||
val uiSessionKey: Long = System.currentTimeMillis()
|
||||
|
||||
init {
|
||||
initializeOneTimeDonationState(oneTimeDonationRepository)
|
||||
@@ -178,6 +179,7 @@ class DonateToSignalViewModel(
|
||||
private fun createGatewayRequest(snapshot: DonateToSignalState): GatewayRequest {
|
||||
val amount = getAmount(snapshot)
|
||||
return GatewayRequest(
|
||||
uiSessionKey = uiSessionKey,
|
||||
donateToSignalType = snapshot.donateToSignalType,
|
||||
badge = snapshot.badge!!,
|
||||
label = snapshot.badge!!.description,
|
||||
|
||||
@@ -43,6 +43,7 @@ import java.util.Currency
|
||||
class DonationCheckoutDelegate(
|
||||
private val fragment: Fragment,
|
||||
private val callback: Callback,
|
||||
private val uiSessionKey: Long,
|
||||
errorSource: DonationErrorSource,
|
||||
vararg additionalSources: DonationErrorSource
|
||||
) : DefaultLifecycleObserver {
|
||||
@@ -65,7 +66,7 @@ class DonationCheckoutDelegate(
|
||||
|
||||
init {
|
||||
fragment.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
ErrorHandler().attach(fragment, callback, errorSource, *additionalSources)
|
||||
ErrorHandler().attach(fragment, callback, uiSessionKey, errorSource, *additionalSources)
|
||||
}
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
@@ -100,6 +101,7 @@ class DonationCheckoutDelegate(
|
||||
GatewayResponse.Gateway.GOOGLE_PAY -> launchGooglePay(gatewayResponse)
|
||||
GatewayResponse.Gateway.PAYPAL -> launchPayPal(gatewayResponse)
|
||||
GatewayResponse.Gateway.CREDIT_CARD -> launchCreditCard(gatewayResponse)
|
||||
GatewayResponse.Gateway.SEPA_DEBIT -> launchSEPADebit(gatewayResponse)
|
||||
}
|
||||
} else {
|
||||
error("Unsupported combination! ${gatewayResponse.gateway} ${gatewayResponse.request.donateToSignalType}")
|
||||
@@ -154,6 +156,10 @@ class DonationCheckoutDelegate(
|
||||
callback.navigateToCreditCardForm(gatewayResponse.request)
|
||||
}
|
||||
|
||||
private fun launchSEPADebit(gatewayResponse: GatewayResponse) {
|
||||
callback.navigateToBankTransferMandate(gatewayResponse.request)
|
||||
}
|
||||
|
||||
private fun registerGooglePayCallback() {
|
||||
disposables += donationPaymentComponent.googlePayResultPublisher.subscribeBy(
|
||||
onNext = { paymentResult ->
|
||||
@@ -206,7 +212,7 @@ class DonationCheckoutDelegate(
|
||||
private var errorDialog: DialogInterface? = null
|
||||
private var userCancelledFlowCallback: UserCancelledFlowCallback? = null
|
||||
|
||||
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
fun attach(fragment: Fragment, userCancelledFlowCallback: UserCancelledFlowCallback?, uiSessionKey: Long, errorSource: DonationErrorSource, vararg additionalSources: DonationErrorSource) {
|
||||
this.fragment = fragment
|
||||
this.userCancelledFlowCallback = userCancelledFlowCallback
|
||||
|
||||
@@ -218,6 +224,8 @@ class DonationCheckoutDelegate(
|
||||
additionalSources.forEach { source ->
|
||||
disposables += registerErrorSource(source)
|
||||
}
|
||||
|
||||
disposables += registerUiSession(uiSessionKey)
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
@@ -234,6 +242,14 @@ class DonationCheckoutDelegate(
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerUiSession(uiSessionKey: Long): Disposable {
|
||||
return DonationError.getErrorsForUiSessionKey(uiSessionKey)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
showErrorDialog(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showErrorDialog(throwable: Throwable) {
|
||||
if (errorDialog != null) {
|
||||
Log.d(TAG, "Already displaying an error dialog. Skipping.", throwable, true)
|
||||
@@ -281,6 +297,7 @@ class DonationCheckoutDelegate(
|
||||
fun navigateToStripePaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToPayPalPaymentInProgress(gatewayRequest: GatewayRequest)
|
||||
fun navigateToCreditCardForm(gatewayRequest: GatewayRequest)
|
||||
fun navigateToBankTransferMandate(gatewayRequest: GatewayRequest)
|
||||
fun onPaymentComplete(gatewayRequest: GatewayRequest)
|
||||
fun onProcessorActionProcessed()
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class CreditCardFragment : Fragment(R.layout.credit_card_fragment) {
|
||||
DonateToSignalType.GIFT -> DonationErrorSource.GIFT
|
||||
}
|
||||
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, errorSource)
|
||||
DonationCheckoutDelegate.ErrorHandler().attach(this, null, args.request.uiSessionKey, errorSource)
|
||||
|
||||
setFragmentResultListener(StripePaymentInProgressFragment.REQUEST_KEY) { _, bundle ->
|
||||
val result: DonationProcessorActionResult = bundle.getParcelableCompat(StripePaymentInProgressFragment.REQUEST_KEY, DonationProcessorActionResult::class.java)!!
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.Currency
|
||||
|
||||
@Parcelize
|
||||
data class GatewayRequest(
|
||||
val uiSessionKey: Long,
|
||||
val donateToSignalType: DonateToSignalType,
|
||||
val badge: Badge,
|
||||
val label: String,
|
||||
|
||||
@@ -9,13 +9,15 @@ data class GatewayResponse(val gateway: Gateway, val request: GatewayRequest) :
|
||||
enum class Gateway {
|
||||
GOOGLE_PAY,
|
||||
PAYPAL,
|
||||
CREDIT_CARD;
|
||||
CREDIT_CARD,
|
||||
SEPA_DEBIT;
|
||||
|
||||
fun toPaymentSourceType(): PaymentSourceType {
|
||||
return when (this) {
|
||||
GOOGLE_PAY -> PaymentSourceType.Stripe.GooglePay
|
||||
PAYPAL -> PaymentSourceType.PayPal
|
||||
CREDIT_CARD -> PaymentSourceType.Stripe.CreditCard
|
||||
SEPA_DEBIT -> PaymentSourceType.Stripe.SEPADebit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,20 @@ class GatewaySelectorBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isSEPADebitAvailable) {
|
||||
space(8.dp)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.GatewaySelectorBottomSheet__bank_transfer),
|
||||
icon = DSLSettingsIcon.from(R.drawable.credit_card, NO_TINT), // TODO [sepa] -- Final icon
|
||||
onClick = {
|
||||
findNavController().popBackStack()
|
||||
val response = GatewayResponse(GatewayResponse.Gateway.SEPA_DEBIT, args.request)
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to response))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
space(16.dp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class GatewaySelectorRepository(
|
||||
when (it) {
|
||||
"PAYPAL" -> listOf(GatewayResponse.Gateway.PAYPAL)
|
||||
"CARD" -> listOf(GatewayResponse.Gateway.CREDIT_CARD, GatewayResponse.Gateway.GOOGLE_PAY)
|
||||
"SEPA_DEBIT" -> listOf(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
else -> listOf()
|
||||
}
|
||||
}.flatten().toSet()
|
||||
|
||||
@@ -7,5 +7,6 @@ data class GatewaySelectorState(
|
||||
val badge: Badge,
|
||||
val isGooglePayAvailable: Boolean = false,
|
||||
val isPayPalAvailable: Boolean = false,
|
||||
val isCreditCardAvailable: Boolean = false
|
||||
val isCreditCardAvailable: Boolean = false,
|
||||
val isSEPADebitAvailable: Boolean = false
|
||||
)
|
||||
|
||||
@@ -24,7 +24,8 @@ class GatewaySelectorViewModel(
|
||||
badge = args.request.badge,
|
||||
isGooglePayAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.GooglePay, args.request.donateToSignalType),
|
||||
isCreditCardAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.CreditCard, args.request.donateToSignalType),
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType)
|
||||
isPayPalAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.PayPal, args.request.donateToSignalType),
|
||||
isSEPADebitAvailable = InAppDonations.isPaymentSourceAvailable(PaymentSourceType.Stripe.SEPADebit, args.request.donateToSignalType)
|
||||
)
|
||||
)
|
||||
private val disposables = CompositeDisposable()
|
||||
@@ -41,7 +42,8 @@ class GatewaySelectorViewModel(
|
||||
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)
|
||||
isPayPalAvailable = it.isPayPalAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.PAYPAL),
|
||||
isSEPADebitAvailable = it.isSEPADebitAvailable && gatewaysAvailable.contains(GatewayResponse.Gateway.SEPA_DEBIT)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -28,6 +29,7 @@ import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentInt
|
||||
import org.whispersystems.signalservice.api.subscriptions.PayPalCreatePaymentMethodResponse
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
|
||||
|
||||
class PayPalPaymentInProgressViewModel(
|
||||
private val payPalRepository: PayPalRepository,
|
||||
@@ -43,7 +45,6 @@ class PayPalPaymentInProgressViewModel(
|
||||
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
override fun onCleared() {
|
||||
store.dispose()
|
||||
disposables.clear()
|
||||
@@ -82,7 +83,7 @@ class PayPalPaymentInProgressViewModel(
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -90,10 +91,10 @@ class PayPalPaymentInProgressViewModel(
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
@@ -157,7 +158,8 @@ class PayPalPaymentInProgressViewModel(
|
||||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.PAYPAL
|
||||
donationProcessor = DonationProcessor.PAYPAL,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
@@ -166,10 +168,10 @@ class PayPalPaymentInProgressViewModel(
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
@@ -190,16 +192,16 @@ class PayPalPaymentInProgressViewModel(
|
||||
.flatMapCompletable { payPalRepository.setDefaultPaymentMethod(it.paymentId) }
|
||||
.onErrorResumeNext { Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, PaymentSourceType.PayPal)) }
|
||||
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += setup.andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failure in monthly payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.PayPal)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
|
||||
@@ -23,12 +23,14 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.donate.Do
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.gateway.GatewayRequest
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.toDonationError
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceSubscriptionSyncRequestJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
import org.whispersystems.signalservice.internal.push.DonationProcessor
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.DonationProcessorError
|
||||
|
||||
class StripePaymentInProgressViewModel(
|
||||
private val stripeRepository: StripeRepository,
|
||||
@@ -44,8 +46,7 @@ class StripePaymentInProgressViewModel(
|
||||
val state: Flowable<DonationProcessorStage> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private var paymentData: PaymentData? = null
|
||||
private var cardData: StripeApi.CardData? = null
|
||||
private var stripePaymentData: StripePaymentData? = null
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
@@ -87,19 +88,18 @@ class StripePaymentInProgressViewModel(
|
||||
}
|
||||
|
||||
private fun resolvePaymentSourceProvider(errorSource: DonationErrorSource): PaymentSourceProvider {
|
||||
val paymentData = this.paymentData
|
||||
val cardData = this.cardData
|
||||
|
||||
return when {
|
||||
paymentData == null && cardData == null -> error("No payment provider available.")
|
||||
paymentData != null && cardData != null -> error("Too many providers available")
|
||||
paymentData != null -> PaymentSourceProvider(
|
||||
return when (val data = stripePaymentData) {
|
||||
is StripePaymentData.GooglePay -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.GooglePay,
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
Single.just<StripeApi.PaymentSource>(GooglePayPaymentSource(data.paymentData)).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
cardData != null -> PaymentSourceProvider(
|
||||
is StripePaymentData.CreditCard -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.CreditCard,
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
stripeRepository.createCreditCardPaymentSource(errorSource, data.cardData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
is StripePaymentData.SEPADebit -> PaymentSourceProvider(
|
||||
PaymentSourceType.Stripe.SEPADebit,
|
||||
stripeRepository.createSEPADebitPaymentSource(data.sepaDebitData).doAfterTerminate { clearPaymentInformation() }
|
||||
)
|
||||
else -> error("This should never happen.")
|
||||
}
|
||||
@@ -107,29 +107,35 @@ class StripePaymentInProgressViewModel(
|
||||
|
||||
fun providePaymentData(paymentData: PaymentData) {
|
||||
requireNoPaymentInformation()
|
||||
this.paymentData = paymentData
|
||||
this.stripePaymentData = StripePaymentData.GooglePay(paymentData)
|
||||
}
|
||||
|
||||
fun provideCardData(cardData: StripeApi.CardData) {
|
||||
requireNoPaymentInformation()
|
||||
this.cardData = cardData
|
||||
this.stripePaymentData = StripePaymentData.CreditCard(cardData)
|
||||
}
|
||||
|
||||
fun provideSEPADebitData(bankData: StripeApi.SEPADebitData) {
|
||||
requireNoPaymentInformation()
|
||||
this.stripePaymentData = StripePaymentData.SEPADebit(bankData)
|
||||
}
|
||||
|
||||
private fun requireNoPaymentInformation() {
|
||||
require(paymentData == null)
|
||||
require(cardData == null)
|
||||
require(stripePaymentData == null)
|
||||
}
|
||||
|
||||
private fun clearPaymentInformation() {
|
||||
Log.d(TAG, "Cleared payment information.", true)
|
||||
paymentData = null
|
||||
cardData = null
|
||||
stripePaymentData = null
|
||||
}
|
||||
|
||||
private fun proceedMonthly(request: GatewayRequest, paymentSourceProvider: PaymentSourceProvider, nextActionHandler: (StripeApi.Secure3DSAction) -> Single<StripeIntentAccessor>) {
|
||||
val ensureSubscriberId: Completable = monthlyDonationRepository.ensureSubscriberId()
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap { stripeRepository.createAndConfirmSetupIntent(it) }
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString())
|
||||
val createAndConfirmSetupIntent: Single<StripeApi.Secure3DSAction> = paymentSourceProvider.paymentSource.flatMap {
|
||||
stripeRepository.createAndConfirmSetupIntent(it, paymentSourceProvider.paymentSourceType as PaymentSourceType.Stripe)
|
||||
}
|
||||
|
||||
val setLevel: Completable = monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey)
|
||||
|
||||
Log.d(TAG, "Starting subscription payment pipeline...", true)
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
@@ -144,10 +150,10 @@ class StripePaymentInProgressViewModel(
|
||||
}
|
||||
.flatMapCompletable { stripeRepository.setDefaultPaymentMethod(it, paymentSourceProvider.paymentSourceType) }
|
||||
.onErrorResumeNext {
|
||||
if (it is DonationError) {
|
||||
Completable.error(it)
|
||||
} else {
|
||||
Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
|
||||
when {
|
||||
it is DonationError -> Completable.error(it)
|
||||
it is DonationProcessorError -> Completable.error(it.toDonationError(DonationErrorSource.SUBSCRIPTION, paymentSourceProvider.paymentSourceType))
|
||||
else -> Completable.error(DonationError.getPaymentSetupError(DonationErrorSource.SUBSCRIPTION, it, paymentSourceProvider.paymentSourceType))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +204,8 @@ class StripePaymentInProgressViewModel(
|
||||
badgeRecipient = request.recipientId,
|
||||
additionalMessage = request.additionalMessage,
|
||||
badgeLevel = request.level,
|
||||
donationProcessor = DonationProcessor.STRIPE
|
||||
donationProcessor = DonationProcessor.STRIPE,
|
||||
uiSessionKey = request.uiSessionKey
|
||||
)
|
||||
}
|
||||
}.subscribeBy(
|
||||
@@ -206,10 +213,10 @@ class StripePaymentInProgressViewModel(
|
||||
Log.w(TAG, "Failure in one-time payment pipeline...", throwable, true)
|
||||
store.update { DonationProcessorStage.FAILED }
|
||||
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.BOOST)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(request.donateToSignalType.toErrorSource(), paymentSourceProvider.paymentSourceType)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(request.donateToSignalType.toErrorSource())
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
},
|
||||
@@ -243,7 +250,7 @@ class StripePaymentInProgressViewModel(
|
||||
Log.d(TAG, "Beginning subscription update...", true)
|
||||
|
||||
store.update { DonationProcessorStage.PAYMENT_PIPELINE }
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString()))
|
||||
disposables += monthlyDonationRepository.cancelActiveSubscriptionIfNecessary().andThen(monthlyDonationRepository.setSubscriptionLevel(request.level.toString(), request.uiSessionKey))
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
Log.w(TAG, "Completed subscription update", true)
|
||||
@@ -251,10 +258,10 @@ class StripePaymentInProgressViewModel(
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Failed to update subscription", throwable, true)
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
val donationError: DonationError = when (throwable) {
|
||||
is DonationError -> throwable
|
||||
is DonationProcessorError -> throwable.toDonationError(DonationErrorSource.SUBSCRIPTION, PaymentSourceType.Stripe.GooglePay)
|
||||
else -> DonationError.genericBadgeRedemptionFailure(DonationErrorSource.SUBSCRIPTION)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
|
||||
@@ -268,6 +275,12 @@ class StripePaymentInProgressViewModel(
|
||||
val paymentSource: Single<StripeApi.PaymentSource>
|
||||
)
|
||||
|
||||
private sealed interface StripePaymentData {
|
||||
class GooglePay(val paymentData: PaymentData) : StripePaymentData
|
||||
class CreditCard(val cardData: StripeApi.CardData) : StripePaymentData
|
||||
class SEPADebit(val sepaDebitData: StripeApi.SEPADebitData) : StripePaymentData
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val monthlyDonationRepository: MonthlyDonationRepository = MonthlyDonationRepository(ApplicationDependencies.getDonationsService()),
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment.Companion.Center
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import androidx.navigation.navGraphViewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.Texts
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalType
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonationProcessorAction
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.donate.stripe.StripePaymentInProgressViewModel
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Collects SEPA Debit bank transfer details from the user to proceed with donation.
|
||||
*/
|
||||
class BankTransferDetailsFragment : ComposeFragment() {
|
||||
|
||||
private val args: BankTransferDetailsFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferDetailsViewModel by viewModels()
|
||||
|
||||
private val stripePaymentViewModel: StripePaymentInProgressViewModel by navGraphViewModels(
|
||||
R.id.donate_to_signal,
|
||||
factoryProducer = {
|
||||
StripePaymentInProgressViewModel.Factory(requireListener<DonationPaymentComponent>().stripeRepository)
|
||||
}
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state: BankTransferDetailsState by viewModel.state
|
||||
|
||||
val donateLabel = remember(args.request) {
|
||||
if (args.request.donateToSignalType == DonateToSignalType.MONTHLY) {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s_month,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal())
|
||||
)
|
||||
} else {
|
||||
getString(
|
||||
R.string.BankTransferDetailsFragment__donate_s,
|
||||
FiatMoneyUtil.format(resources, args.request.fiat)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
BankTransferDetailsContent(
|
||||
state = state,
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onNameChanged = viewModel::onNameChanged,
|
||||
onIBANChanged = viewModel::onIBANChanged,
|
||||
onEmailChanged = viewModel::onEmailChanged,
|
||||
onFindAccountNumbersClicked = this::onFindAccountNumbersClicked,
|
||||
onDonateClick = this::onDonateClick,
|
||||
onIBANFocusChanged = viewModel::onIBANFocusChanged,
|
||||
donateLabel = donateLabel
|
||||
)
|
||||
}
|
||||
|
||||
private fun onNavigationClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun onFindAccountNumbersClicked() {
|
||||
// TODO [sepa] -- FindAccountNumbersBottomSheet
|
||||
}
|
||||
|
||||
private fun onDonateClick() {
|
||||
stripePaymentViewModel.provideSEPADebitData(viewModel.state.value.asSEPADebitData())
|
||||
findNavController().safeNavigate(
|
||||
BankTransferDetailsFragmentDirections.actionBankTransferDetailsFragmentToStripePaymentInProgressFragment(
|
||||
DonationProcessorAction.PROCESS_NEW_DONATION,
|
||||
args.request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun BankTransferDetailsContentPreview() {
|
||||
SignalTheme {
|
||||
BankTransferDetailsContent(
|
||||
state = BankTransferDetailsState(
|
||||
name = "Miles Morales"
|
||||
),
|
||||
onNavigationClick = {},
|
||||
onNameChanged = {},
|
||||
onIBANChanged = {},
|
||||
onEmailChanged = {},
|
||||
onFindAccountNumbersClicked = {},
|
||||
onDonateClick = {},
|
||||
onIBANFocusChanged = {},
|
||||
donateLabel = "Donate $5/month"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BankTransferDetailsContent(
|
||||
state: BankTransferDetailsState,
|
||||
onNavigationClick: () -> Unit,
|
||||
onNameChanged: (String) -> Unit,
|
||||
onIBANChanged: (String) -> Unit,
|
||||
onEmailChanged: (String) -> Unit,
|
||||
onFindAccountNumbersClicked: () -> Unit,
|
||||
onDonateClick: () -> Unit,
|
||||
onIBANFocusChanged: (Boolean) -> Unit,
|
||||
donateLabel: String
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "Bank transfer",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(it)
|
||||
) {
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferDetailsFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferDetailsFragment__enter_your_bank_details, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.iban,
|
||||
onValueChange = onIBANChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__iban))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Characters,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
isError = state.ibanValidity.isError,
|
||||
supportingText = {
|
||||
if (state.ibanValidity.isError) {
|
||||
Text(
|
||||
text = when (state.ibanValidity) {
|
||||
IBANValidator.Validity.TOO_SHORT -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_short)
|
||||
IBANValidator.Validity.TOO_LONG -> stringResource(id = R.string.BankTransferDetailsFragment__iban_nubmer_is_too_long)
|
||||
IBANValidator.Validity.INVALID_COUNTRY -> stringResource(id = R.string.BankTransferDetailsFragment__iban_country_code_is_not_supported)
|
||||
IBANValidator.Validity.INVALID_CHARACTERS -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
IBANValidator.Validity.INVALID_MOD_97 -> stringResource(id = R.string.BankTransferDetailsFragment__invalid_iban_nubmer)
|
||||
else -> error("Unexpected error.")
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = IBANVisualTransformation,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp)
|
||||
.onFocusChanged { onIBANFocusChanged(it.hasFocus) }
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.name,
|
||||
onValueChange = onNameChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__name_on_bank_account))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onNext = { focusManager.moveFocus(FocusDirection.Down) }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
TextField(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
label = {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__email))
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { onDonateClick() }
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Box(
|
||||
contentAlignment = Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onFindAccountNumbersClicked
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferDetailsFragment__find_account_numbers))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
enabled = state.canProceed,
|
||||
onClick = onDonateClick,
|
||||
modifier = Modifier
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Text(text = donateLabel)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import org.signal.donations.StripeApi
|
||||
|
||||
data class BankTransferDetailsState(
|
||||
val name: String = "",
|
||||
val iban: String = "",
|
||||
val email: String = "",
|
||||
val ibanValidity: IBANValidator.Validity = IBANValidator.Validity.POTENTIALLY_VALID
|
||||
) {
|
||||
val canProceed = name.isNotEmpty() && email.isNotEmpty() && ibanValidity == IBANValidator.Validity.COMPLETELY_VALID
|
||||
|
||||
fun asSEPADebitData(): StripeApi.SEPADebitData {
|
||||
return StripeApi.SEPADebitData(
|
||||
iban = iban,
|
||||
name = name,
|
||||
email = email
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class BankTransferDetailsViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val IBAN_MAX_CHARACTER_COUNT = 34
|
||||
}
|
||||
|
||||
private val internalState = mutableStateOf(BankTransferDetailsState())
|
||||
val state: State<BankTransferDetailsState> = internalState
|
||||
|
||||
fun onNameChanged(name: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
name = name
|
||||
)
|
||||
}
|
||||
|
||||
fun onIBANFocusChanged(isFocused: Boolean) {
|
||||
internalState.value = internalState.value.copy(
|
||||
ibanValidity = IBANValidator.validate(internalState.value.iban, isFocused)
|
||||
)
|
||||
}
|
||||
|
||||
fun onIBANChanged(iban: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
iban = iban.take(IBAN_MAX_CHARACTER_COUNT).uppercase(),
|
||||
ibanValidity = IBANValidator.validate(internalState.value.iban, true)
|
||||
)
|
||||
}
|
||||
|
||||
fun onEmailChanged(email: String) {
|
||||
internalState.value = internalState.value.copy(
|
||||
email = email
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import java.math.BigInteger
|
||||
|
||||
object IBANValidator {
|
||||
|
||||
private val countryCodeToLength: Map<String, Int> by lazy {
|
||||
mapOf(
|
||||
"AL" to 28,
|
||||
"AD" to 24,
|
||||
"AT" to 20,
|
||||
"AZ" to 28,
|
||||
"BH" to 22,
|
||||
"BY" to 28,
|
||||
"BE" to 16,
|
||||
"BA" to 20,
|
||||
"BR" to 29,
|
||||
"BG" to 22,
|
||||
"CR" to 22,
|
||||
"HR" to 21,
|
||||
"CY" to 28,
|
||||
"CZ" to 24,
|
||||
"DK" to 18,
|
||||
"DO" to 28,
|
||||
"TL" to 23,
|
||||
"EG" to 29,
|
||||
"SV" to 28,
|
||||
"EE" to 20,
|
||||
"FO" to 18,
|
||||
"FI" to 18,
|
||||
"FR" to 27,
|
||||
"GE" to 22,
|
||||
"DE" to 22,
|
||||
"GI" to 23,
|
||||
"GR" to 27,
|
||||
"GL" to 18,
|
||||
"GT" to 28,
|
||||
"HU" to 28,
|
||||
"IS" to 26,
|
||||
"IQ" to 23,
|
||||
"IE" to 22,
|
||||
"IL" to 23,
|
||||
"IT" to 27,
|
||||
"JO" to 30,
|
||||
"KZ" to 20,
|
||||
"XK" to 20,
|
||||
"KW" to 30,
|
||||
"LV" to 21,
|
||||
"LB" to 28,
|
||||
"LY" to 25,
|
||||
"LI" to 21,
|
||||
"LT" to 20,
|
||||
"LU" to 20,
|
||||
"MT" to 31,
|
||||
"MR" to 27,
|
||||
"MU" to 30,
|
||||
"MC" to 27,
|
||||
"MD" to 24,
|
||||
"ME" to 22,
|
||||
"NL" to 18,
|
||||
"MK" to 19,
|
||||
"NO" to 15,
|
||||
"PK" to 24,
|
||||
"PS" to 29,
|
||||
"PL" to 28,
|
||||
"PT" to 25,
|
||||
"QA" to 29,
|
||||
"RO" to 24,
|
||||
"RU" to 33,
|
||||
"LC" to 32,
|
||||
"SM" to 27,
|
||||
"ST" to 25,
|
||||
"SA" to 24,
|
||||
"RS" to 22,
|
||||
"SC" to 31,
|
||||
"SK" to 24,
|
||||
"SI" to 19,
|
||||
"ES" to 24,
|
||||
"SD" to 18,
|
||||
"SE" to 24,
|
||||
"CH" to 21,
|
||||
"TN" to 24,
|
||||
"TR" to 26,
|
||||
"UA" to 29,
|
||||
"AE" to 23,
|
||||
"GB" to 22,
|
||||
"VA" to 22,
|
||||
"VG" to 24
|
||||
)
|
||||
}
|
||||
|
||||
fun validate(iban: String, isIBANFieldFocused: Boolean): Validity {
|
||||
if (iban.isEmpty()) {
|
||||
return Validity.POTENTIALLY_VALID
|
||||
}
|
||||
|
||||
val lengthValidity = validateLength(iban, isIBANFieldFocused)
|
||||
if (lengthValidity != Validity.COMPLETELY_VALID) {
|
||||
return lengthValidity
|
||||
}
|
||||
|
||||
val countryAndCheck = iban.take(4)
|
||||
val rearranged = iban.drop(4) + countryAndCheck
|
||||
val expanded = rearranged.map {
|
||||
if (it.isLetter()) {
|
||||
(it - 'A') + 10
|
||||
} else if (it.isDigit()) {
|
||||
it.digitToInt()
|
||||
} else {
|
||||
return Validity.INVALID_CHARACTERS
|
||||
}
|
||||
}.joinToString("")
|
||||
val bigInteger = BigInteger(expanded)
|
||||
if (bigInteger.mod(BigInteger.valueOf(97L)) == BigInteger.ONE) {
|
||||
return Validity.COMPLETELY_VALID
|
||||
}
|
||||
|
||||
return Validity.INVALID_MOD_97
|
||||
}
|
||||
|
||||
private fun validateLength(iban: String, isIBANFieldFocused: Boolean): Validity {
|
||||
if (iban.length < 2) {
|
||||
return if (isIBANFieldFocused) {
|
||||
Validity.POTENTIALLY_VALID
|
||||
} else {
|
||||
Validity.TOO_SHORT
|
||||
}
|
||||
}
|
||||
|
||||
val countryCode = iban.take(2)
|
||||
val requiredLength = countryCodeToLength[countryCode] ?: -1
|
||||
if (requiredLength == -1) {
|
||||
return Validity.INVALID_COUNTRY
|
||||
}
|
||||
|
||||
if (requiredLength > iban.length) {
|
||||
return if (isIBANFieldFocused) Validity.POTENTIALLY_VALID else Validity.TOO_SHORT
|
||||
}
|
||||
|
||||
if (requiredLength < iban.length) {
|
||||
return Validity.TOO_LONG
|
||||
}
|
||||
|
||||
return Validity.COMPLETELY_VALID
|
||||
}
|
||||
|
||||
enum class Validity(val isError: Boolean) {
|
||||
TOO_SHORT(true),
|
||||
TOO_LONG(true),
|
||||
INVALID_COUNTRY(true),
|
||||
INVALID_CHARACTERS(true),
|
||||
INVALID_MOD_97(true),
|
||||
POTENTIALLY_VALID(false),
|
||||
COMPLETELY_VALID(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.details
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.input.OffsetMapping
|
||||
import androidx.compose.ui.text.input.TransformedText
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
|
||||
/**
|
||||
* Transforms the given input string to an IBAN representative format:
|
||||
*
|
||||
* AB1234567890 becomes AB12 3456 7890
|
||||
*/
|
||||
object IBANVisualTransformation : VisualTransformation {
|
||||
override fun filter(text: AnnotatedString): TransformedText {
|
||||
var output = ""
|
||||
for (i in text.take(34).indices) {
|
||||
output += text[i]
|
||||
if (i % 4 == 3) {
|
||||
output += " "
|
||||
}
|
||||
}
|
||||
|
||||
return TransformedText(
|
||||
text = AnnotatedString(output),
|
||||
offsetMapping = IBANOffsetMapping
|
||||
)
|
||||
}
|
||||
|
||||
private object IBANOffsetMapping : OffsetMapping {
|
||||
override fun originalToTransformed(offset: Int): Int {
|
||||
return offset + (offset / 4)
|
||||
}
|
||||
|
||||
override fun transformedToOriginal(offset: Int): Int {
|
||||
return offset - (offset / 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.settings.app.subscription.donate.transfer.mandate
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment.Companion.CenterHorizontally
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.navigation.fragment.navArgs
|
||||
import 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.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Displays Bank Transfer legal mandate users must agree to to move forward.
|
||||
*/
|
||||
class BankTransferMandateFragment : ComposeFragment() {
|
||||
|
||||
private val args: BankTransferMandateFragmentArgs by navArgs()
|
||||
private val viewModel: BankTransferMandateViewModel by viewModels()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val mandate by viewModel.mandate
|
||||
|
||||
BankTransferScreen(
|
||||
bankMandate = mandate,
|
||||
onNavigationClick = this::onNavigationClick,
|
||||
onContinueClick = this::onContinueClick
|
||||
)
|
||||
}
|
||||
|
||||
private fun onNavigationClick() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
|
||||
private fun onContinueClick() {
|
||||
findNavController().safeNavigate(
|
||||
BankTransferMandateFragmentDirections.actionBankTransferMandateFragmentToBankTransferDetailsFragment(args.request)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun BankTransferScreenPreview() {
|
||||
SignalTheme {
|
||||
BankTransferScreen(
|
||||
bankMandate = "Test ".repeat(500),
|
||||
onNavigationClick = {},
|
||||
onContinueClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BankTransferScreen(
|
||||
bankMandate: String,
|
||||
onNavigationClick: () -> Unit,
|
||||
onContinueClick: () -> Unit
|
||||
) {
|
||||
Scaffolds.Settings(
|
||||
title = "",
|
||||
onNavigationClick = onNavigationClick,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(id = R.drawable.symbol_arrow_left_24))
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.padding(top = 64.dp)
|
||||
) {
|
||||
item {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.credit_card), // TODO [alex] -- final asset
|
||||
contentScale = ContentScale.Inside,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.background(
|
||||
SignalTheme.colors.colorSurface2,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.BankTransferMandateFragment__bank_transfer),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
val learnMore = stringResource(id = R.string.BankTransferMandateFragment__learn_more)
|
||||
val fullString = stringResource(id = R.string.BankTransferMandateFragment__stripe_processes_donations, learnMore)
|
||||
val context = LocalContext.current
|
||||
|
||||
Texts.LinkifiedText(
|
||||
textWithUrlSpans = SpanUtil.urlSubsequence(fullString, learnMore, stringResource(id = R.string.donate_url)), // TODO [alex] -- final URL
|
||||
onUrlClick = {
|
||||
CommunicationActions.openBrowserLink(context, it)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp, start = 32.dp, end = 32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = bankMandate,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.LargeTonal(
|
||||
onClick = onContinueClick,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp, bottom = 46.dp)
|
||||
.defaultMinSize(minWidth = 220.dp)
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.BankTransferMandateFragment__continue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user