Compare commits
495 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ec6f0a71f | ||
|
|
40c1dd23da | ||
|
|
0fe0765e63 | ||
|
|
6e6752cfed | ||
|
|
0107e8e6eb | ||
|
|
7fe5376772 | ||
|
|
30d2d12f89 | ||
|
|
98ab48f0eb | ||
|
|
a181ed0420 | ||
|
|
dbddb274db | ||
|
|
8502badb6d | ||
|
|
cb4ba1ccfe | ||
|
|
3b16a1d28c | ||
|
|
ba1473acb9 | ||
|
|
709c866786 | ||
|
|
ab4e5b1d7c | ||
|
|
7b2552e8f2 | ||
|
|
a501940909 | ||
|
|
a3bbf944e5 | ||
|
|
97d41fdd1e | ||
|
|
a9bdc1abfc | ||
|
|
ad626fe7ee | ||
|
|
d97184ef60 | ||
|
|
b527b2ffb9 | ||
|
|
468cda034a | ||
|
|
8f2c5d43df | ||
|
|
9bc4dfc3f6 | ||
|
|
dc095c9db4 | ||
|
|
ef85b29ddf | ||
|
|
392a66ed59 | ||
|
|
c078d08df7 | ||
|
|
c89b818a31 | ||
|
|
e495c25687 | ||
|
|
3b2a3500a1 | ||
|
|
d3d9b95924 | ||
|
|
12d1254d4e | ||
|
|
ecc358ef40 | ||
|
|
bb963f9210 | ||
|
|
820277800b | ||
|
|
14b2d12895 | ||
|
|
92a506e4da | ||
|
|
12e6ebb4df | ||
|
|
c0db88960c | ||
|
|
eeb4cdf064 | ||
|
|
85cecbb7e9 | ||
|
|
33d60ebe14 | ||
|
|
9afeb206fc | ||
|
|
06a49b5d5a | ||
|
|
68ba3433a3 | ||
|
|
eaf36be9f6 | ||
|
|
af9465fefe | ||
|
|
8ca0f4baf4 | ||
|
|
0c1edd6a56 | ||
|
|
df88c2fd14 | ||
|
|
c698bfca44 | ||
|
|
431f5501c6 | ||
|
|
9a20447993 | ||
|
|
049e5a1b99 | ||
|
|
4cbacc9804 | ||
|
|
6462d053ae | ||
|
|
0f08acbc04 | ||
|
|
dc5f7d0906 | ||
|
|
60b20a9b8a | ||
|
|
ec361d6349 | ||
|
|
1f8f1d433b | ||
|
|
bc44704f54 | ||
|
|
756eafe3c8 | ||
|
|
e770241ed4 | ||
|
|
4b8729c2ae | ||
|
|
8261e21005 | ||
|
|
1b1bbbab7a | ||
|
|
964d214434 | ||
|
|
e2b0079a5c | ||
|
|
158f77a634 | ||
|
|
1345413645 | ||
|
|
ee69895123 | ||
|
|
f25f47654e | ||
|
|
8f52f803cf | ||
|
|
82d42c03f7 | ||
|
|
c0f8e5adbf | ||
|
|
c54c73cb48 | ||
|
|
02c8656b92 | ||
|
|
3553a28683 | ||
|
|
acf4e97578 | ||
|
|
5142c8c58f | ||
|
|
55919cba59 | ||
|
|
1a6bd3d3f2 | ||
|
|
100dc54292 | ||
|
|
cffbfcb957 | ||
|
|
f73c5dde6b | ||
|
|
d5a466851a | ||
|
|
38836198a1 | ||
|
|
52429dcd33 | ||
|
|
fae427c09b | ||
|
|
e22ddb8f96 | ||
|
|
19f0722df3 | ||
|
|
921f7a70b3 | ||
|
|
bb8faebc7d | ||
|
|
5ed6a05eb9 | ||
|
|
a4a4665aaa | ||
|
|
5d16d1cd23 | ||
|
|
38b6362b25 | ||
|
|
ef0c6c79cb | ||
|
|
f10d5651f0 | ||
|
|
8a2f89b4f6 | ||
|
|
6563ea970f | ||
|
|
f1cb416bda | ||
|
|
df48e5ce92 | ||
|
|
e710e231ad | ||
|
|
9599d3a0b6 | ||
|
|
1fad4d4f65 | ||
|
|
f57e06677b | ||
|
|
f7b9942f11 | ||
|
|
2f1b05f882 | ||
|
|
6650f41200 | ||
|
|
2d8de03e05 | ||
|
|
9ffa866907 | ||
|
|
3c0c5478b5 | ||
|
|
aae888f5af | ||
|
|
e00a3730b4 | ||
|
|
5c2394aa4f | ||
|
|
c6be273a38 | ||
|
|
33236ea8e6 | ||
|
|
a6f1e0e972 | ||
|
|
fa8f8beb56 | ||
|
|
11db59d8a1 | ||
|
|
39a11ce26c | ||
|
|
8bb1b2d596 | ||
|
|
3f1abe05fc | ||
|
|
08ac99b4c1 | ||
|
|
ebf2ef65e2 | ||
|
|
8cb74fb776 | ||
|
|
eccb796199 | ||
|
|
4635a77fbc | ||
|
|
9505c3d070 | ||
|
|
657a9c7b0a | ||
|
|
e22560a794 | ||
|
|
c081193373 | ||
|
|
d23faf4278 | ||
|
|
da7e4cefd5 | ||
|
|
0d9a5ef9a6 | ||
|
|
e5aea7c49e | ||
|
|
b8c42fa57e | ||
|
|
2a086ad574 | ||
|
|
33346d8033 | ||
|
|
1446af97a2 | ||
|
|
64b5dad783 | ||
|
|
a6e7f9a4c1 | ||
|
|
70b0a120f0 | ||
|
|
4a6569fa1c | ||
|
|
f5173fa6f5 | ||
|
|
5478285362 | ||
|
|
e2292dfa34 | ||
|
|
17111abc72 | ||
|
|
c4bc2162f2 | ||
|
|
bfd966217f | ||
|
|
797c02e893 | ||
|
|
65372e547a | ||
|
|
2454b2e0db | ||
|
|
0505a46603 | ||
|
|
7f77cd6a22 | ||
|
|
efe7b3099f | ||
|
|
26a831b49f | ||
|
|
a3a5bb8177 | ||
|
|
4282f3eb6d | ||
|
|
8a49db650a | ||
|
|
fadd4ac61e | ||
|
|
d0c14895d0 | ||
|
|
32ee18240b | ||
|
|
cd10aa90cc | ||
|
|
33d28c4359 | ||
|
|
530403ec04 | ||
|
|
f15072bc8d | ||
|
|
8c2db972cf | ||
|
|
ff8f9ca81a | ||
|
|
40991cc8e9 | ||
|
|
2f551ee3f2 | ||
|
|
f1ab0a05f1 | ||
|
|
fb919466de | ||
|
|
4a4cf08cd8 | ||
|
|
ed20c24326 | ||
|
|
e01cbcec62 | ||
|
|
04d6ccc30e | ||
|
|
6860f96973 | ||
|
|
944c8530d8 | ||
|
|
8b1552952c | ||
|
|
dfcadde076 | ||
|
|
55acd0f048 | ||
|
|
820c016aad | ||
|
|
d1d63d83dc | ||
|
|
7da5b2cdef | ||
|
|
442dde5c40 | ||
|
|
f038e81ff3 | ||
|
|
32c4fcb065 | ||
|
|
e2703b459f | ||
|
|
405d99fbe2 | ||
|
|
7b89687206 | ||
|
|
d74f1a386c | ||
|
|
b041ed1510 | ||
|
|
3426556a51 | ||
|
|
e2cb535f3f | ||
|
|
3b17a41415 | ||
|
|
631720f111 | ||
|
|
ab031d3dad | ||
|
|
6101048f07 | ||
|
|
115f7063d5 | ||
|
|
159d67ec59 | ||
|
|
e09ce4c820 | ||
|
|
8cfc013960 | ||
|
|
a436c46cb2 | ||
|
|
893be51810 | ||
|
|
97b5a49e36 | ||
|
|
043f06e188 | ||
|
|
fa13b464f8 | ||
|
|
bfaaf20fd9 | ||
|
|
2f97b80b9c | ||
|
|
eee9c967fa | ||
|
|
515981c044 | ||
|
|
a06528e5e1 | ||
|
|
98194c854a | ||
|
|
2d60a88a75 | ||
|
|
c3e7d6c74c | ||
|
|
8da66bc789 | ||
|
|
9ceb5b2e85 | ||
|
|
17b8e086c9 | ||
|
|
9a097d113d | ||
|
|
46ca1e16bb | ||
|
|
d4d3124a90 | ||
|
|
35a9fddbb2 | ||
|
|
41e417ff0b | ||
|
|
f6614c1174 | ||
|
|
9136bcf5e8 | ||
|
|
7c156d10d6 | ||
|
|
3372d942ec | ||
|
|
7fc9876b1e | ||
|
|
cff62e9528 | ||
|
|
24f59b0a17 | ||
|
|
0a07800eba | ||
|
|
c863e9ed4d | ||
|
|
523537cf05 | ||
|
|
fd4543ffe0 | ||
|
|
83b0309f23 | ||
|
|
5cabe5ecfa | ||
|
|
fae3004512 | ||
|
|
e143c47c25 | ||
|
|
27c3fca324 | ||
|
|
26d637cafc | ||
|
|
03e8fe9f27 | ||
|
|
23939aeee3 | ||
|
|
d7b793ce4c | ||
|
|
d3096c56cb | ||
|
|
b0e7b49056 | ||
|
|
2f0f26c328 | ||
|
|
7f2f5a182f | ||
|
|
33b88796e8 | ||
|
|
31e4db2186 | ||
|
|
76ad7866ec | ||
|
|
e9804eccbb | ||
|
|
d62d0efb1d | ||
|
|
3ec9cd1244 | ||
|
|
bd5f48f193 | ||
|
|
98fc3e5b0b | ||
|
|
d06c633dc4 | ||
|
|
d401386e2d | ||
|
|
8f0f9e64b9 | ||
|
|
bd5ac85ac0 | ||
|
|
417070e957 | ||
|
|
a92638e897 | ||
|
|
08abe890ff | ||
|
|
66b6420f21 | ||
|
|
b21bd5a01e | ||
|
|
d11e8ec04b | ||
|
|
3e5fe0f1cb | ||
|
|
b65d62e065 | ||
|
|
fc55be0916 | ||
|
|
a87aa0fbe2 | ||
|
|
a44a105cbc | ||
|
|
c4817ac017 | ||
|
|
65835606cc | ||
|
|
ff26922afb | ||
|
|
a894ba7a51 | ||
|
|
cb63fe600c | ||
|
|
2d6146351d | ||
|
|
e5953b25e1 | ||
|
|
6354cb194c | ||
|
|
8d6beb92cb | ||
|
|
bb5edccf34 | ||
|
|
6d86b25acd | ||
|
|
04677d21bb | ||
|
|
20022b88fc | ||
|
|
3088d7f182 | ||
|
|
ce8dafd33d | ||
|
|
6054285ddb | ||
|
|
dabea5169b | ||
|
|
7fb5ceeda4 | ||
|
|
dc6fd8be7f | ||
|
|
c271b9c2de | ||
|
|
6fb6092a6b | ||
|
|
46bb64ad24 | ||
|
|
bcd16ce296 | ||
|
|
07ec14d5c4 | ||
|
|
d716416d1d | ||
|
|
343871ed8b | ||
|
|
aa60247e42 | ||
|
|
283e3e99a5 | ||
|
|
ca79bdb16b | ||
|
|
a75d2cfa34 | ||
|
|
1746f37276 | ||
|
|
73f32868a2 | ||
|
|
dabd131222 | ||
|
|
612c6db6db | ||
|
|
c56ef33833 | ||
|
|
2253e25ae1 | ||
|
|
adb24d480a | ||
|
|
9fb1dcf28f | ||
|
|
bba36a5724 | ||
|
|
fa515be258 | ||
|
|
be241524db | ||
|
|
bb66c3fa68 | ||
|
|
a32d5bef20 | ||
|
|
d409278dd5 | ||
|
|
3328e43a40 | ||
|
|
678e832058 | ||
|
|
2c341f450f | ||
|
|
5854074d4a | ||
|
|
dbe186248d | ||
|
|
0504161b04 | ||
|
|
9748f1cff8 | ||
|
|
6a061ed52c | ||
|
|
102d58502a | ||
|
|
477698f917 | ||
|
|
d865b5d7b5 | ||
|
|
eeaf6df925 | ||
|
|
95abca4e03 | ||
|
|
c5906b6f3a | ||
|
|
91c581b475 | ||
|
|
47760867d5 | ||
|
|
5612a5d9e4 | ||
|
|
4c462bd75a | ||
|
|
092b30f64f | ||
|
|
b34ca8ca2f | ||
|
|
e2c54eef77 | ||
|
|
2a2c27edef | ||
|
|
ec92d5ddb7 | ||
|
|
a9f208153c | ||
|
|
d9ffd67f36 | ||
|
|
19861ef0d1 | ||
|
|
469879c211 | ||
|
|
157198fd17 | ||
|
|
0d61b8db38 | ||
|
|
593334456a | ||
|
|
98b9cc23e4 | ||
|
|
3e42c044b8 | ||
|
|
11c3ea769e | ||
|
|
b8bb2e234b | ||
|
|
87b00bb156 | ||
|
|
3da2fc4d9b | ||
|
|
972ab9b368 | ||
|
|
c359b0134a | ||
|
|
2cd7462573 | ||
|
|
2a7d515932 | ||
|
|
1bb04035ab | ||
|
|
267efb0763 | ||
|
|
0ef215dfc5 | ||
|
|
50bea8140f | ||
|
|
086e3ed4ec | ||
|
|
b02539684a | ||
|
|
b9747607ad | ||
|
|
284a6ae667 | ||
|
|
116e711f1a | ||
|
|
7aeb641036 | ||
|
|
e4f69c0b6f | ||
|
|
48d7228ae7 | ||
|
|
ae28df901f | ||
|
|
f17f45f277 | ||
|
|
2b15fc2966 | ||
|
|
76a9342afa | ||
|
|
14849d6e45 | ||
|
|
77ea2deada | ||
|
|
390b7ff834 | ||
|
|
0e4187b062 | ||
|
|
4098f77e08 | ||
|
|
a60eed35fe | ||
|
|
904215fe38 | ||
|
|
6376642d38 | ||
|
|
6b36a446f0 | ||
|
|
95ee7f5c00 | ||
|
|
b109effc94 | ||
|
|
44efda8318 | ||
|
|
e6e1b6d746 | ||
|
|
e303cbcc22 | ||
|
|
c67aed5b65 | ||
|
|
0278882c30 | ||
|
|
42ccd638bd | ||
|
|
e11577bd23 | ||
|
|
3c0b87bbca | ||
|
|
84a61b01ca | ||
|
|
c149e008fd | ||
|
|
c7a345eb0b | ||
|
|
348b6e9742 | ||
|
|
003b3e02e4 | ||
|
|
5b668d7931 | ||
|
|
87748fa80c | ||
|
|
ad0482fb5b | ||
|
|
12ceb1cb32 | ||
|
|
9ec2c5da52 | ||
|
|
f742d34588 | ||
|
|
4d8e058d33 | ||
|
|
77ba6e0f7b | ||
|
|
76a0e5c851 | ||
|
|
f3096cc24c | ||
|
|
49957e1d95 | ||
|
|
2f5cb5f090 | ||
|
|
ba394e1021 | ||
|
|
7611c64493 | ||
|
|
231248d20a | ||
|
|
a3a79fc58d | ||
|
|
6476e585c4 | ||
|
|
fd2961710d | ||
|
|
7f4ab67f98 | ||
|
|
b9ce38b85b | ||
|
|
50d5658add | ||
|
|
72777bc6cd | ||
|
|
f2046c3c05 | ||
|
|
745dfc3fbb | ||
|
|
cb77165b53 | ||
|
|
bde4700e87 | ||
|
|
e58cea9a26 | ||
|
|
556dc0d1ec | ||
|
|
8c1ddcf1c0 | ||
|
|
2549c1f97d | ||
|
|
5faa497821 | ||
|
|
d7a7e72c3a | ||
|
|
af1701e6fa | ||
|
|
32d1cc7d54 | ||
|
|
783a615c07 | ||
|
|
65bfee6eba | ||
|
|
8d4419705b | ||
|
|
6c3baf229c | ||
|
|
6e9a6283fc | ||
|
|
5b3899237b | ||
|
|
dddf830e47 | ||
|
|
fd930d0b1d | ||
|
|
2b5d65ae04 | ||
|
|
2ebaa04c2f | ||
|
|
1e316ea19f | ||
|
|
ac9257ec1c | ||
|
|
9b83c5e283 | ||
|
|
a7a4972013 | ||
|
|
f6f4e6fde7 | ||
|
|
e9160c2449 | ||
|
|
b0b1029d0f | ||
|
|
72b3a0555d | ||
|
|
135fde68c1 | ||
|
|
954e45ed97 | ||
|
|
2b3f16d3ad | ||
|
|
6820b84921 | ||
|
|
6a5f5f4ffa | ||
|
|
19381342b3 | ||
|
|
c2627dda8d | ||
|
|
db309b7930 | ||
|
|
403958fed3 | ||
|
|
3af53f2089 | ||
|
|
e472760d92 | ||
|
|
b04acd8ae0 | ||
|
|
6890973ce8 | ||
|
|
b3d9a85fa2 | ||
|
|
9f027ed584 | ||
|
|
8fb598e60a | ||
|
|
2edaba39a0 | ||
|
|
b0be7effe8 | ||
|
|
142979ce93 | ||
|
|
093dd7c62c | ||
|
|
4acafc3d77 | ||
|
|
65bf0aad79 | ||
|
|
ef6e846512 | ||
|
|
782464f664 | ||
|
|
c7352f62e5 | ||
|
|
90dd6b7cb3 | ||
|
|
ddfb4bf0a5 | ||
|
|
cdef21d6c0 | ||
|
|
c0f843061e | ||
|
|
5774771ea6 | ||
|
|
ab8d5474e0 | ||
|
|
6497ec8098 | ||
|
|
83c3b16b92 | ||
|
|
3c2bd032ba | ||
|
|
f798866619 | ||
|
|
ffad2c7386 | ||
|
|
7252e54593 | ||
|
|
cfab4dc658 | ||
|
|
7f5a8ce6bb | ||
|
|
d02a597451 | ||
|
|
8d92a1f195 | ||
|
|
74e630aacb |
23
.github/stale.yml
vendored
@@ -0,0 +1,23 @@
|
||||
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||
daysUntilStale: 60
|
||||
|
||||
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||
daysUntilClose: 7
|
||||
|
||||
issues:
|
||||
exemptLabels:
|
||||
- acknowledged
|
||||
|
||||
# Comment to post when marking as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
|
||||
# Comment to post when closing a stale Issue or Pull Request.
|
||||
closeComment: >
|
||||
This issue has been closed due to inactivity.
|
||||
|
||||
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||
limitPerRun: 1
|
||||
|
||||
2
.github/workflows/android.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'main'
|
||||
- '4.**'
|
||||
- '5.**'
|
||||
|
||||
|
||||
8
.idea/fileTemplates/ViewModel.kt
generated
@@ -1,18 +1,18 @@
|
||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
#end
|
||||
#parse("File Header.java")
|
||||
class ${NAME}ViewModel : ViewModel() {
|
||||
|
||||
private val store = Store(${NAME}State())
|
||||
private val store = RxStore(${NAME}State())
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: LiveData<${NAME}State> = store.stateLiveData
|
||||
val state: Flowable<${NAME}State> = store.stateFlowable
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
|
||||
@@ -28,7 +28,7 @@ https://www.transifex.com/projects/p/signal-android/
|
||||
|
||||
## Contributing Code
|
||||
|
||||
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md), that might answer some of your questions.
|
||||
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions.
|
||||
|
||||
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ try:
|
||||
print("\nTo include this in the distribution, copy it to the project's assets/databases/ directory.")
|
||||
print("If you support API 10 or lower, you must use the gzipped version to avoid corruption.")
|
||||
|
||||
except sqlite3.Error, e:
|
||||
except sqlite3.Error as e:
|
||||
if connection:
|
||||
connection.rollback()
|
||||
print("Error: %s" % e.args[0])
|
||||
|
||||
@@ -63,15 +63,15 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1029
|
||||
def canonicalVersionName = "5.34.3"
|
||||
def canonicalVersionCode = 1055
|
||||
def canonicalVersionName = "5.38.6"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
'armeabi-v7a' : 1,
|
||||
'arm64-v8a' : 2,
|
||||
'x86' : 3,
|
||||
'x86_64' : 4]
|
||||
def abiPostFix = ['universal' : 5,
|
||||
'armeabi-v7a' : 6,
|
||||
'arm64-v8a' : 7,
|
||||
'x86' : 8,
|
||||
'x86_64' : 9]
|
||||
|
||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||
|
||||
@@ -455,8 +455,9 @@ dependencies {
|
||||
implementation project(':device-transfer')
|
||||
implementation project(':image-editor')
|
||||
implementation project(':donations')
|
||||
implementation project(':contacts')
|
||||
|
||||
implementation libs.signal.client.android
|
||||
implementation libs.libsignal.android
|
||||
implementation libs.google.protobuf.javalite
|
||||
|
||||
implementation(libs.mobilecoin) {
|
||||
@@ -551,7 +552,7 @@ dependencies {
|
||||
implementation libs.rxjava3.rxkotlin
|
||||
implementation libs.rxdogtag
|
||||
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.0'
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.1'
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
-dontobfuscate
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-keep class org.whispersystems.** { *; }
|
||||
-keep class org.signal.libsignal.protocol.** { *; }
|
||||
-keep class org.thoughtcrime.securesms.** { *; }
|
||||
-keepclassmembers class ** {
|
||||
public void onEvent*(**);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
class DonationReceiptDatabaseTest {
|
||||
|
||||
private val records = listOf(
|
||||
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(100), Currency.getInstance("USD"))),
|
||||
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(200), Currency.getInstance("USD")))
|
||||
)
|
||||
|
||||
@Test
|
||||
fun givenNoReceipts_whenICheckHasReceipts_thenIExpectFalse() {
|
||||
assertFalse(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOneReceipt_whenICheckHasReceipts_thenIExpectTrue() {
|
||||
SignalDatabase.donationReceipts.addReceipt(records.first())
|
||||
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleReceipts_whenICheckHasReceipts_thenIExpectTrue() {
|
||||
records.forEach {
|
||||
SignalDatabase.donationReceipts.addReceipt(it)
|
||||
}
|
||||
|
||||
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MmsDatabaseTest_stories {
|
||||
|
||||
private lateinit var mms: MmsDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
private lateinit var myStory: Recipient
|
||||
private lateinit var recipients: List<RecipientId>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mms = SignalDatabase.mms
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectAnEmptyList() {
|
||||
// WHEN
|
||||
val result = mms.orderedStoryRecipientsAndIds
|
||||
|
||||
// THEN
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOneOutgoingAndOneIncomingStory_whenIGetOrderedStoryRecipientsAndIds_thenIExpectIncomingThenOutgoing() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
val sender = recipients[0]
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 1,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = threadId
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.orderedStoryRecipientsAndIds
|
||||
|
||||
// THEN
|
||||
assertEquals(listOf(sender.toLong(), myStory.id.toLong()), result.map { it.recipientId.toLong() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAStory_whenISetIncomingStoryMessageViewed_thenIExpectASetReceiptTimestamp() {
|
||||
// GIVEN
|
||||
val sender = recipients[0]
|
||||
val messageId = MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
|
||||
val messageBeforeMark = SignalDatabase.mms.getMessageRecord(messageId)
|
||||
assertFalse(messageBeforeMark.incomingStoryViewedAtTimestamp > 0)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.mms.setIncomingMessageViewed(messageId)
|
||||
|
||||
// THEN
|
||||
val messageAfterMark = SignalDatabase.mms.getMessageRecord(messageId)
|
||||
assertTrue(messageAfterMark.incomingStoryViewedAtTimestamp > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given5ViewedStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectLatestViewedFirst() {
|
||||
// GIVEN
|
||||
val messageIds = recipients.take(5).map {
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = it,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val randomizedOrderedIds = messageIds.shuffled()
|
||||
randomizedOrderedIds.forEach {
|
||||
SignalDatabase.mms.setIncomingMessageViewed(it)
|
||||
Thread.sleep(5)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
val result = SignalDatabase.mms.orderedStoryRecipientsAndIds
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
// THEN
|
||||
assertEquals(randomizedOrderedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given15Stories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectUnviewedThenInterspersedViewedAndSelfSendsAllDescending() {
|
||||
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
|
||||
val unviewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val viewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val interspersedIds: List<Long> = (0 until 10).map {
|
||||
Thread.sleep(5)
|
||||
if (it % 2 == 0) {
|
||||
SignalDatabase.mms.setIncomingMessageViewed(viewedIds[it / 2])
|
||||
viewedIds[it / 2]
|
||||
} else {
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = myStoryThread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val result = SignalDatabase.mms.orderedStoryRecipientsAndIds
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Helper methods for inserting an MMS message into the MMS table.
|
||||
*/
|
||||
object MmsHelper {
|
||||
|
||||
fun insert(
|
||||
recipient: Recipient = Recipient.UNKNOWN,
|
||||
body: String = "body",
|
||||
sentTimeMillis: Long = System.currentTimeMillis(),
|
||||
subscriptionId: Int = -1,
|
||||
expiresIn: Long = 0,
|
||||
viewOnce: Boolean = false,
|
||||
distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
threadId: Long = 1,
|
||||
storyType: StoryType = StoryType.NONE
|
||||
): Long {
|
||||
val message = OutgoingMediaMessage(
|
||||
recipient,
|
||||
body,
|
||||
emptyList(),
|
||||
sentTimeMillis,
|
||||
subscriptionId,
|
||||
expiresIn,
|
||||
viewOnce,
|
||||
distributionType,
|
||||
storyType,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptySet(),
|
||||
emptySet(),
|
||||
null
|
||||
)
|
||||
|
||||
return insert(
|
||||
message = message,
|
||||
threadId = threadId
|
||||
)
|
||||
}
|
||||
|
||||
fun insert(
|
||||
message: OutgoingMediaMessage,
|
||||
threadId: Long
|
||||
): Long {
|
||||
return SignalDatabase.mms.insertMessageOutbox(message, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null)
|
||||
}
|
||||
|
||||
fun insert(
|
||||
message: IncomingMediaMessage,
|
||||
threadId: Long
|
||||
): Optional<MessageDatabase.InsertResult> {
|
||||
return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.hamcrest.MatcherAssert
|
||||
import org.hamcrest.Matchers
|
||||
import org.junit.Assert
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
@@ -8,25 +11,55 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientDatabaseTest {
|
||||
class RecipientDatabaseTest_getAndPossiblyMerge {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
private lateinit var identityDatabase: IdentityDatabase
|
||||
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
|
||||
private lateinit var groupDatabase: GroupDatabase
|
||||
private lateinit var threadDatabase: ThreadDatabase
|
||||
private lateinit var smsDatabase: MessageDatabase
|
||||
private lateinit var mmsDatabase: MessageDatabase
|
||||
private lateinit var sessionDatabase: SessionDatabase
|
||||
private lateinit var mentionDatabase: MentionDatabase
|
||||
private lateinit var reactionDatabase: ReactionDatabase
|
||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
||||
private lateinit var distributionListDatabase: DistributionListDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
@@ -34,6 +67,19 @@ class RecipientDatabaseTest {
|
||||
@Before
|
||||
fun setup() {
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
identityDatabase = SignalDatabase.identities
|
||||
groupReceiptDatabase = SignalDatabase.groupReceipts
|
||||
groupDatabase = SignalDatabase.groups
|
||||
threadDatabase = SignalDatabase.threads
|
||||
smsDatabase = SignalDatabase.sms
|
||||
mmsDatabase = SignalDatabase.mms
|
||||
sessionDatabase = SignalDatabase.sessions
|
||||
mentionDatabase = SignalDatabase.mentions
|
||||
reactionDatabase = SignalDatabase.reactions
|
||||
notificationProfileDatabase = SignalDatabase.notificationProfiles
|
||||
distributionListDatabase = SignalDatabase.distributionLists
|
||||
|
||||
ensureDbEmpty()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
@@ -478,6 +524,140 @@ class RecipientDatabaseTest {
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_merge_general() {
|
||||
// Setup
|
||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
|
||||
|
||||
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
||||
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
||||
|
||||
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
|
||||
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
|
||||
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
|
||||
assertNotEquals(threadIdAci, threadIdE164)
|
||||
|
||||
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
|
||||
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
|
||||
|
||||
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
|
||||
|
||||
val identityKeyAci: IdentityKey = identityKey(1)
|
||||
val identityKeyE164: IdentityKey = identityKey(2)
|
||||
|
||||
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
|
||||
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
|
||||
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
|
||||
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
|
||||
|
||||
val profile1: NotificationProfile = notificationProfile(name = "Test")
|
||||
val profile2: NotificationProfile = notificationProfile(name = "Test2")
|
||||
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
||||
|
||||
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
||||
assertEquals(recipientIdAci, retrievedId)
|
||||
|
||||
// Recipient validation
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(recipientIdE164)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
// Thread validation
|
||||
assertEquals(threadIdAci, retrievedThreadId)
|
||||
Assert.assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
|
||||
Assert.assertNull(threadDatabase.getThreadRecord(threadIdE164))
|
||||
|
||||
// SMS validation
|
||||
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
|
||||
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
|
||||
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
|
||||
|
||||
assertEquals(retrievedId, sms1.recipient.id)
|
||||
assertEquals(retrievedId, sms2.recipient.id)
|
||||
assertEquals(retrievedId, sms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, sms1.threadId)
|
||||
assertEquals(retrievedThreadId, sms2.threadId)
|
||||
assertEquals(retrievedThreadId, sms3.threadId)
|
||||
|
||||
// MMS validation
|
||||
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
|
||||
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
|
||||
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
|
||||
|
||||
assertEquals(retrievedId, mms1.recipient.id)
|
||||
assertEquals(retrievedId, mms2.recipient.id)
|
||||
assertEquals(retrievedId, mms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, mms1.threadId)
|
||||
assertEquals(retrievedThreadId, mms2.threadId)
|
||||
assertEquals(retrievedThreadId, mms3.threadId)
|
||||
|
||||
// Mention validation
|
||||
val mention1: MentionModel = getMention(mmsId1)
|
||||
assertEquals(retrievedId, mention1.recipientId)
|
||||
assertEquals(retrievedThreadId, mention1.threadId)
|
||||
|
||||
val mention2: MentionModel = getMention(mmsId2)
|
||||
assertEquals(retrievedId, mention2.recipientId)
|
||||
assertEquals(retrievedThreadId, mention2.threadId)
|
||||
|
||||
// Group receipt validation
|
||||
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
|
||||
assertEquals(retrievedId, groupReceipts[0].recipientId)
|
||||
assertEquals(retrievedId, groupReceipts[1].recipientId)
|
||||
|
||||
// Identity validation
|
||||
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
|
||||
Assert.assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
|
||||
|
||||
// Session validation
|
||||
Assert.assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
|
||||
// Reaction validation
|
||||
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
|
||||
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
|
||||
|
||||
assertEquals(1, reactionsSms.size)
|
||||
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
|
||||
|
||||
assertEquals(1, reactionsMms.size)
|
||||
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
|
||||
|
||||
// Notification Profile validation
|
||||
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
|
||||
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
|
||||
|
||||
MatcherAssert.assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
|
||||
MatcherAssert.assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
|
||||
// Distribution List validation
|
||||
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
|
||||
|
||||
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// Misc
|
||||
// ==============================================================
|
||||
@@ -522,12 +702,59 @@ class RecipientDatabaseTest {
|
||||
}
|
||||
|
||||
private fun ensureDbEmpty() {
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME} WHERE ${RecipientDatabase.DISTRIBUTION_LIST_ID} IS NULL ", null).use { cursor ->
|
||||
assertTrue(cursor.moveToFirst())
|
||||
assertEquals(0, cursor.getLong(0))
|
||||
}
|
||||
}
|
||||
|
||||
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
|
||||
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
|
||||
}
|
||||
|
||||
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
|
||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
val bytes = ByteArray(33)
|
||||
bytes[0] = 0x05
|
||||
bytes[1] = value
|
||||
return IdentityKey(bytes)
|
||||
}
|
||||
|
||||
private fun notificationProfile(name: String): NotificationProfile {
|
||||
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
|
||||
}
|
||||
|
||||
private fun groupMasterKey(value: Byte): GroupMasterKey {
|
||||
val bytes = ByteArray(32)
|
||||
bytes[0] = value
|
||||
return GroupMasterKey(bytes)
|
||||
}
|
||||
|
||||
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
|
||||
return DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getMention(messageId: Long): MentionModel {
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return MentionModel(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
|
||||
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
|
||||
data class MentionModel(
|
||||
val recipientId: RecipientId,
|
||||
val threadId: Long
|
||||
)
|
||||
|
||||
private class ChangeNumberListener {
|
||||
|
||||
var numberChangeWasEnqueued = false
|
||||
@@ -1,271 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.Matchers
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.signal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.Mention
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.thoughtcrime.securesms.util.CursorUtil
|
||||
import org.whispersystems.libsignal.IdentityKey
|
||||
import org.whispersystems.libsignal.SignalProtocolAddress
|
||||
import org.whispersystems.libsignal.state.SessionRecord
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientDatabaseTest_merges {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
private lateinit var identityDatabase: IdentityDatabase
|
||||
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
|
||||
private lateinit var groupDatabase: GroupDatabase
|
||||
private lateinit var threadDatabase: ThreadDatabase
|
||||
private lateinit var smsDatabase: MessageDatabase
|
||||
private lateinit var mmsDatabase: MessageDatabase
|
||||
private lateinit var sessionDatabase: SessionDatabase
|
||||
private lateinit var mentionDatabase: MentionDatabase
|
||||
private lateinit var reactionDatabase: ReactionDatabase
|
||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
||||
private lateinit var distributionListDatabase: DistributionListDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
identityDatabase = SignalDatabase.identities
|
||||
groupReceiptDatabase = SignalDatabase.groupReceipts
|
||||
groupDatabase = SignalDatabase.groups
|
||||
threadDatabase = SignalDatabase.threads
|
||||
smsDatabase = SignalDatabase.sms
|
||||
mmsDatabase = SignalDatabase.mms
|
||||
sessionDatabase = SignalDatabase.sessions
|
||||
mentionDatabase = SignalDatabase.mentions
|
||||
reactionDatabase = SignalDatabase.reactions
|
||||
notificationProfileDatabase = SignalDatabase.notificationProfiles
|
||||
distributionListDatabase = SignalDatabase.distributionLists
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
}
|
||||
|
||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_general() {
|
||||
// Setup
|
||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
|
||||
|
||||
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
||||
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
||||
|
||||
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
|
||||
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
|
||||
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
|
||||
assertNotEquals(threadIdAci, threadIdE164)
|
||||
|
||||
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
|
||||
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
|
||||
|
||||
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
|
||||
|
||||
val identityKeyAci: IdentityKey = identityKey(1)
|
||||
val identityKeyE164: IdentityKey = identityKey(2)
|
||||
|
||||
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
|
||||
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
|
||||
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
|
||||
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
|
||||
|
||||
val profile1: NotificationProfile = notificationProfile(name = "Test")
|
||||
val profile2: NotificationProfile = notificationProfile(name = "Test2")
|
||||
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
||||
|
||||
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
||||
assertEquals(recipientIdAci, retrievedId)
|
||||
|
||||
// Recipient validation
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(recipientIdE164)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
// Thread validation
|
||||
assertEquals(threadIdAci, retrievedThreadId)
|
||||
assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
|
||||
assertNull(threadDatabase.getThreadRecord(threadIdE164))
|
||||
|
||||
// SMS validation
|
||||
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
|
||||
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
|
||||
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
|
||||
|
||||
assertEquals(retrievedId, sms1.recipient.id)
|
||||
assertEquals(retrievedId, sms2.recipient.id)
|
||||
assertEquals(retrievedId, sms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, sms1.threadId)
|
||||
assertEquals(retrievedThreadId, sms2.threadId)
|
||||
assertEquals(retrievedThreadId, sms3.threadId)
|
||||
|
||||
// MMS validation
|
||||
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
|
||||
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
|
||||
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
|
||||
|
||||
assertEquals(retrievedId, mms1.recipient.id)
|
||||
assertEquals(retrievedId, mms2.recipient.id)
|
||||
assertEquals(retrievedId, mms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, mms1.threadId)
|
||||
assertEquals(retrievedThreadId, mms2.threadId)
|
||||
assertEquals(retrievedThreadId, mms3.threadId)
|
||||
|
||||
// Mention validation
|
||||
val mention1: MentionModel = getMention(mmsId1)
|
||||
assertEquals(retrievedId, mention1.recipientId)
|
||||
assertEquals(retrievedThreadId, mention1.threadId)
|
||||
|
||||
val mention2: MentionModel = getMention(mmsId2)
|
||||
assertEquals(retrievedId, mention2.recipientId)
|
||||
assertEquals(retrievedThreadId, mention2.threadId)
|
||||
|
||||
// Group receipt validation
|
||||
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
|
||||
assertEquals(retrievedId, groupReceipts[0].recipientId)
|
||||
assertEquals(retrievedId, groupReceipts[1].recipientId)
|
||||
|
||||
// Identity validation
|
||||
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
|
||||
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
|
||||
|
||||
// Session validation
|
||||
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
|
||||
// Reaction validation
|
||||
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
|
||||
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
|
||||
|
||||
assertEquals(1, reactionsSms.size)
|
||||
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
|
||||
|
||||
assertEquals(1, reactionsMms.size)
|
||||
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
|
||||
|
||||
// Notification Profile validation
|
||||
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
|
||||
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
|
||||
|
||||
assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
|
||||
assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
|
||||
// Distribution List validation
|
||||
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
|
||||
|
||||
assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
private val context: Application
|
||||
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
||||
|
||||
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
|
||||
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
|
||||
}
|
||||
|
||||
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
|
||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty())
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
val bytes = ByteArray(33)
|
||||
bytes[0] = 0x05
|
||||
bytes[1] = value
|
||||
return IdentityKey(bytes)
|
||||
}
|
||||
|
||||
private fun groupMasterKey(value: Byte): GroupMasterKey {
|
||||
val bytes = ByteArray(32)
|
||||
bytes[0] = value
|
||||
return GroupMasterKey(bytes)
|
||||
}
|
||||
|
||||
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
|
||||
return DecryptedGroup.newBuilder()
|
||||
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun getMention(messageId: Long): MentionModel {
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return MentionModel(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
|
||||
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationProfile(name: String): NotificationProfile {
|
||||
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
|
||||
}
|
||||
|
||||
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
|
||||
data class MentionModel(
|
||||
val recipientId: RecipientId,
|
||||
val threadId: Long
|
||||
)
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
|
||||
val E164_A = "+12221234567"
|
||||
val E164_B = "+13331234567"
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import org.hamcrest.Matchers.nullValue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.addMember
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.addRequestingMember
|
||||
@@ -20,7 +21,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
|
||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||
import org.thoughtcrime.securesms.util.Hex
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import junit.framework.TestCase.assertNull
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.Matchers.containsInAnyOrder
|
||||
import org.hamcrest.Matchers.hasSize
|
||||
import org.hamcrest.Matchers.`is`
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StorySendsDatabaseTest {
|
||||
|
||||
private val distributionId1 = DistributionId.from(UUID.randomUUID())
|
||||
private val distributionId2 = DistributionId.from(UUID.randomUUID())
|
||||
private val distributionId3 = DistributionId.from(UUID.randomUUID())
|
||||
|
||||
private lateinit var distributionList1: DistributionListId
|
||||
private lateinit var distributionList2: DistributionListId
|
||||
private lateinit var distributionList3: DistributionListId
|
||||
|
||||
private lateinit var distributionListRecipient1: Recipient
|
||||
private lateinit var distributionListRecipient2: Recipient
|
||||
private lateinit var distributionListRecipient3: Recipient
|
||||
|
||||
private lateinit var recipients1to10: List<RecipientId>
|
||||
private lateinit var recipients11to20: List<RecipientId>
|
||||
private lateinit var recipients6to15: List<RecipientId>
|
||||
private lateinit var recipients6to10: List<RecipientId>
|
||||
|
||||
private var messageId1: Long = 0
|
||||
private var messageId2: Long = 0
|
||||
private var messageId3: Long = 0
|
||||
|
||||
private lateinit var storySends: StorySendsDatabase
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
storySends = SignalDatabase.storySends
|
||||
|
||||
recipients1to10 = makeRecipients(10)
|
||||
recipients11to20 = makeRecipients(10)
|
||||
|
||||
distributionList1 = SignalDatabase.distributionLists.createList("1", emptyList(), distributionId = distributionId1)!!
|
||||
distributionList2 = SignalDatabase.distributionLists.createList("2", emptyList(), distributionId = distributionId2)!!
|
||||
distributionList3 = SignalDatabase.distributionLists.createList("3", emptyList(), distributionId = distributionId3)!!
|
||||
|
||||
distributionListRecipient1 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList1))
|
||||
distributionListRecipient2 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList2))
|
||||
distributionListRecipient3 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList3))
|
||||
|
||||
messageId1 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
)
|
||||
|
||||
messageId2 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
)
|
||||
|
||||
messageId3 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient3,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
)
|
||||
|
||||
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)
|
||||
recipients6to10 = recipients1to10.takeLast(5)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecipientsToSendTo_noOverlap() {
|
||||
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray()))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecipientsToSendTo_overlap() {
|
||||
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients6to15, 100, true, distributionId2)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(5))
|
||||
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecipientsToSendTo_overlapAll() {
|
||||
val recipient1 = recipients1to10.first()
|
||||
val recipient2 = recipients11to20.first()
|
||||
|
||||
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false, distributionId1)
|
||||
storySends.insert(messageId2, listOf(recipient1), 100, true, distributionId2)
|
||||
storySends.insert(messageId3, listOf(recipient2), 100, true, distributionId3)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
|
||||
val recipientIdsForMessage3 = storySends.getRecipientsToSendTo(messageId3, 100, true)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(0))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(1))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(recipient1))
|
||||
|
||||
assertThat(recipientIdsForMessage3, hasSize(1))
|
||||
assertThat(recipientIdsForMessage3, containsInAnyOrder(recipient2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRecipientsToSendTo_overlapWithEarlierMessage() {
|
||||
storySends.insert(messageId1, recipients6to15, 100, true, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 100, false, distributionId2)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true)
|
||||
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(5))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_noOverlap() {
|
||||
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100)
|
||||
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray()))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||
|
||||
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200)
|
||||
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||
|
||||
assertThat(recipientIdsForMessage1, hasSize(5))
|
||||
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(5))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.takeLast(5).toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
|
||||
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||
|
||||
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||
|
||||
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canReply_storyWithReplies() {
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
|
||||
val canReply = storySends.canReply(recipients1to10[0], 200)
|
||||
|
||||
assertThat(canReply, `is`(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canReply_storyWithoutReplies() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
|
||||
val canReply = storySends.canReply(recipients1to10[0], 200)
|
||||
|
||||
assertThat(canReply, `is`(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canReply_storyWithAndWithoutRepliesOverlap() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients6to10, 200, true, distributionId2)
|
||||
|
||||
val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200)
|
||||
val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200)
|
||||
|
||||
assertThat(message1OnlyRecipientCanReply, `is`(false))
|
||||
assertThat(message2RecipientCanReply, `is`(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASingleStory_whenIGetFullSentStorySyncManifest_thenIExpectNotNull() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||
|
||||
assertNotNull(manifest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNull() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, false, distributionId2)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)
|
||||
|
||||
assertNull(manifest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectOneManifestPerRecipient() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||
|
||||
assertEquals(recipients1to10, manifest.entries.map { it.recipientId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectTwoListsPerRecipient() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||
|
||||
manifest.entries.forEach { entry ->
|
||||
assertEquals(listOf(distributionId1, distributionId2), entry.distributionLists)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectAllRecipientsCanReply() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||
|
||||
manifest.entries.forEach { entry ->
|
||||
assertTrue(entry.allowedToReply)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
|
||||
|
||||
assertNotNull(manifest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId1, recipients11to20, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
assertEquals(recipients1to10.toHashSet(), recipientIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectOnlyRecipientsThatHadStory1() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||
|
||||
val manifestRecipients = results.entries.map { it.recipientId }
|
||||
assertEquals(recipients1to10, manifestRecipients)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId2)
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||
|
||||
assertTrue(results.entries.all { it.allowedToReply })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
val expected = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||
val emptyManifest = SentStorySyncManifest(emptyList())
|
||||
|
||||
storySends.applySentStoryManifest(emptyManifest, 200)
|
||||
val result = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAnIdenticalManifest_whenIApplyRemoteManifest_thenNothingChanges() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 200
|
||||
)
|
||||
|
||||
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||
val expected = storySends.getFullSentStorySyncManifest(messageId4, 200)
|
||||
|
||||
storySends.applySentStoryManifest(expected!!, 200)
|
||||
val result = storySends.getFullSentStorySyncManifest(messageId4, 200)
|
||||
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 200
|
||||
)
|
||||
|
||||
val messageId5 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient2,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 200
|
||||
)
|
||||
|
||||
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
|
||||
|
||||
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
|
||||
|
||||
storySends.applySentStoryManifest(remote, 200)
|
||||
|
||||
assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectSharedMessageToNotBeMarkedRemoteDeleted() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 200
|
||||
)
|
||||
|
||||
val messageId5 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient2,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 200
|
||||
)
|
||||
|
||||
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
|
||||
|
||||
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
|
||||
|
||||
storySends.applySentStoryManifest(remote, 200)
|
||||
|
||||
assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoLocalEntries_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatch() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 2000
|
||||
)
|
||||
|
||||
val remote = SentStorySyncManifest(
|
||||
recipients1to10.map {
|
||||
SentStorySyncManifest.Entry(
|
||||
recipientId = it,
|
||||
allowedToReply = true,
|
||||
distributionLists = listOf(distributionId1)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
storySends.applySentStoryManifest(remote, 2000)
|
||||
|
||||
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
|
||||
assertEquals(remote, local)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNonStoryMessageAtSentTimestamp_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatchAndNoCrashes() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
sentTimeMillis = 2000
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients1to10.first()),
|
||||
sentTimeMillis = 2000
|
||||
)
|
||||
|
||||
val remote = SentStorySyncManifest(
|
||||
recipients1to10.map {
|
||||
SentStorySyncManifest.Entry(
|
||||
recipientId = it,
|
||||
allowedToReply = true,
|
||||
distributionLists = listOf(distributionId1)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
storySends.applySentStoryManifest(remote, 2000)
|
||||
|
||||
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
|
||||
assertEquals(remote, local)
|
||||
}
|
||||
|
||||
private fun makeRecipients(count: Int): List<RecipientId> {
|
||||
return (1..count).map {
|
||||
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.thoughtcrime.securesms.util.Hex;
|
||||
import org.signal.core.util.Hex;
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||
import org.whispersystems.signalservice.api.kbs.KbsData;
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||
|
||||
@@ -179,11 +179,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity android:name=".sharing.ShareActivity"
|
||||
<activity android:name=".sharing.v2.ShareActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:exported="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
@@ -402,8 +403,9 @@
|
||||
|
||||
<activity
|
||||
android:name=".stories.viewer.StoryViewerActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar.StoryViewer"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||
@@ -416,6 +418,11 @@
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
</activity>
|
||||
|
||||
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -534,6 +541,7 @@
|
||||
|
||||
<activity android:name=".blocked.BlockedUsersActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".scribbles.ImageEditorStickerSelectActivity"
|
||||
@@ -698,7 +706,9 @@
|
||||
|
||||
<service android:name=".service.GenericForegroundService"/>
|
||||
|
||||
<service android:name=".gcm.FcmFetchService" />
|
||||
<service android:name=".gcm.FcmFetchBackgroundService" />
|
||||
|
||||
<service android:name=".gcm.FcmFetchForegroundService" />
|
||||
|
||||
<service android:name=".gcm.FcmReceiveService">
|
||||
<intent-filter>
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 87 KiB |
BIN
app/src/main/assets/emoji/People_9.webp
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,14 @@
|
||||
package com.google.android.material.bottomsheet
|
||||
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
|
||||
*/
|
||||
object BottomSheetBehaviorHack {
|
||||
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) {
|
||||
behavior.nestedScrollingChildRef = WeakReference(view)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,6 @@ public final class AppCapabilities {
|
||||
* asking if the user has set a Signal PIN or not.
|
||||
*/
|
||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories());
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories(), FeatureFlags.giftBadges());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.StrictMode;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -35,6 +36,7 @@ import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||
@@ -47,7 +49,6 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NewNetworkConnectivity;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
@@ -60,7 +61,7 @@ import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
@@ -86,6 +87,7 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Environment;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||
@@ -93,11 +95,11 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.security.Security;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
@@ -199,12 +201,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||
.addPostRender(RetrieveReleaseChannelJob::enqueue)
|
||||
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
|
||||
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(NewNetworkConnectivity::start)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.widget.ImageView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.core.app.ActivityOptionsCompat;
|
||||
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
|
||||
@@ -58,6 +59,12 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(@NonNull Context newBase) {
|
||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||
super.onCreate(savedInstanceState, ready);
|
||||
@@ -132,7 +139,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
|
||||
findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
|
||||
|
||||
fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
|
||||
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), toolbar);
|
||||
|
||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
default void updateSelectedState() {
|
||||
// Intentionall Blank.
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
void onQuoteClicked(MmsMessageRecord messageRecord);
|
||||
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
||||
@@ -95,8 +99,11 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onCallToAction(@NonNull String action);
|
||||
void onDonateClicked();
|
||||
void onBlockJoinRequest(@NonNull Recipient recipient);
|
||||
void onRecipientNameClicked(@NonNull RecipientId target);
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
|
||||
/**
|
||||
* This should be used whenever we want to prompt the user to block/unblock a recipient.
|
||||
@@ -83,7 +83,8 @@ public final class BlockUnblockDialog {
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
} else {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
|
||||
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages
|
||||
: R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_send_you_messages);
|
||||
|
||||
if (onBlockAndReportSpam != null) {
|
||||
builder.setNeutralButton(android.R.string.cancel, null);
|
||||
@@ -128,7 +129,8 @@ public final class BlockUnblockDialog {
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
} else {
|
||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
|
||||
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
|
||||
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other
|
||||
: R.string.BlockUnblockDialog_you_will_be_able_to_message_each_other);
|
||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -153,7 +153,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
@Override
|
||||
protected Void doInBackground(Context... params) {
|
||||
try {
|
||||
DirectoryHelper.refreshDirectory(params[0], true);
|
||||
ContactDiscovery.refreshAll(params[0], true);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
@@ -85,7 +85,7 @@ import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -576,7 +576,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
@Override
|
||||
protected Boolean doInBackground(Void... voids) {
|
||||
try {
|
||||
DirectoryHelper.refreshDirectory(context, false);
|
||||
ContactDiscovery.refreshAll(context, false);
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
|
||||
@@ -20,7 +20,11 @@ import androidx.appcompat.widget.Toolbar;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -32,10 +36,6 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
||||
import org.whispersystems.libsignal.InvalidKeyException;
|
||||
import org.whispersystems.libsignal.ecc.Curve;
|
||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
|
||||
|
||||
@@ -10,7 +10,6 @@ import android.view.View;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.Transformations;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
@@ -19,13 +18,13 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLock
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsState;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||
|
||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||
@@ -60,8 +59,6 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
|
||||
|
||||
navigator.onCreate(savedInstanceState);
|
||||
|
||||
handleGroupLinkInIntent(getIntent());
|
||||
handleProxyInIntent(getIntent());
|
||||
handleSignalMeIntent(getIntent());
|
||||
@@ -69,18 +66,6 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
CachedInflater.from(this).clear();
|
||||
|
||||
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||
Transformations.distinctUntilChanged(Transformations.map(conversationListTabsViewModel.getState(), ConversationListTabsState::getTab))
|
||||
.observe(this, tab -> {
|
||||
switch (tab) {
|
||||
case CHATS:
|
||||
getSupportFragmentManager().popBackStack();
|
||||
break;
|
||||
case STORIES:
|
||||
navigator.goToStories();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
updateTabVisibility();
|
||||
}
|
||||
|
||||
@@ -116,6 +101,12 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
updateTabVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings().getTheme());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
if (!navigator.onBackPressed()) {
|
||||
@@ -134,11 +125,11 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_secondary));
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_colorSecondaryContainer));
|
||||
} else {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_primary));
|
||||
navigator.goToChats();
|
||||
conversationListTabsViewModel.onChatsSelected();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,26 +2,19 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stories.landing.StoriesLandingFragment;
|
||||
|
||||
public class MainNavigator {
|
||||
|
||||
public static final String STORIES_TAG = "STORIES";
|
||||
|
||||
public static final int REQUEST_CONFIG_CHANGES = 901;
|
||||
|
||||
private final MainActivity activity;
|
||||
@@ -38,16 +31,6 @@ public class MainNavigator {
|
||||
return ((MainActivity) activity).getNavigator();
|
||||
}
|
||||
|
||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
if (savedInstanceState != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
getFragmentManager().beginTransaction()
|
||||
.add(R.id.fragment_container, ConversationListFragment.newInstance())
|
||||
.commit();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return True if the back pressed was handled in our own custom way, false if it should be given
|
||||
* to the system to do the default behavior.
|
||||
@@ -76,29 +59,6 @@ public class MainNavigator {
|
||||
activity.startActivityForResult(AppSettingsActivity.home(activity), REQUEST_CONFIG_CHANGES);
|
||||
}
|
||||
|
||||
public void goToArchiveList() {
|
||||
getFragmentManager().beginTransaction()
|
||||
.setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end)
|
||||
.replace(R.id.fragment_container, ConversationListArchiveFragment.newInstance())
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
|
||||
public void goToStories() {
|
||||
if (getFragmentManager().findFragmentByTag(STORIES_TAG) == null) {
|
||||
getFragmentManager().beginTransaction()
|
||||
.replace(R.id.fragment_container, new StoriesLandingFragment(), STORIES_TAG)
|
||||
.addToBackStack(null)
|
||||
.commit();
|
||||
}
|
||||
}
|
||||
|
||||
public void goToChats() {
|
||||
if (getFragmentManager().findFragmentByTag(STORIES_TAG) != null) {
|
||||
getFragmentManager().popBackStack();
|
||||
}
|
||||
}
|
||||
|
||||
public void goToGroupCreation() {
|
||||
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.animation.DepthPageTransformer;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase;
|
||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
||||
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
|
||||
@@ -67,7 +71,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.ShareActivity;
|
||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
@@ -87,7 +90,8 @@ import java.util.Objects;
|
||||
public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
implements LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
|
||||
MediaRailAdapter.RailItemListener,
|
||||
MediaPreviewFragment.Events
|
||||
MediaPreviewFragment.Events,
|
||||
VoiceNoteMediaControllerOwner
|
||||
{
|
||||
|
||||
private final static String TAG = Log.tag(MediaPreviewActivity.class);
|
||||
@@ -127,6 +131,8 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
private MediaDatabase.Sorting sorting;
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
|
||||
private @Nullable Cursor cursor = null;
|
||||
|
||||
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
|
||||
@@ -159,6 +165,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
voiceNoteMediaController = new VoiceNoteMediaController(this);
|
||||
viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class);
|
||||
|
||||
fullscreenHelper = new FullscreenHelper(this);
|
||||
@@ -203,23 +210,25 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
else from = "";
|
||||
|
||||
if (showThread) {
|
||||
String to = null;
|
||||
String titleText = null;
|
||||
Recipient threadRecipient = mediaItem.threadRecipient;
|
||||
|
||||
if (threadRecipient != null) {
|
||||
if (mediaItem.outgoing || threadRecipient.isGroup()) {
|
||||
if (mediaItem.outgoing) {
|
||||
if (threadRecipient.isSelf()) {
|
||||
from = getString(R.string.note_to_self);
|
||||
titleText = getString(R.string.note_to_self);
|
||||
} else {
|
||||
to = threadRecipient.getDisplayName(this);
|
||||
titleText = getString(R.string.MediaPreviewActivity_you_to_s, threadRecipient.getDisplayName(this));
|
||||
}
|
||||
} else {
|
||||
to = getString(R.string.MediaPreviewActivity_you);
|
||||
if (threadRecipient.isGroup()) {
|
||||
titleText = getString(R.string.MediaPreviewActivity_s_to_s, from, threadRecipient.getDisplayName(this));
|
||||
} else {
|
||||
titleText = getString(R.string.MediaPreviewActivity_s_to_you, from);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return to != null ? getString(R.string.MediaPreviewActivity_s_to_s, from, to)
|
||||
: from;
|
||||
return titleText != null ? titleText : from;
|
||||
} else {
|
||||
return from;
|
||||
}
|
||||
@@ -286,7 +295,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
|
||||
anchorMarginsToBottomInsets(detailsContainer);
|
||||
|
||||
fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
|
||||
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), findViewById(R.id.toolbar));
|
||||
|
||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
|
||||
}
|
||||
@@ -386,10 +395,12 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
|
||||
if (mediaItem != null) {
|
||||
Intent composeIntent = new Intent(this, ShareActivity.class);
|
||||
composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri);
|
||||
composeIntent.setType(mediaItem.type);
|
||||
startActivity(composeIntent);
|
||||
MultiselectForwardFragmentArgs.create(
|
||||
this,
|
||||
mediaItem.uri,
|
||||
mediaItem.type,
|
||||
args -> MultiselectForwardFragment.showBottomSheet(getSupportFragmentManager(), args)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -597,6 +608,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaReady() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return voiceNoteMediaController;
|
||||
}
|
||||
|
||||
private class ViewPagerListener extends ExtendedOnPageChangedListener {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -25,7 +25,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||
@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -79,7 +79,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
|
||||
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
|
||||
try {
|
||||
DirectoryHelper.refreshDirectoryFor(this, resolved, false);
|
||||
ContactDiscovery.refresh(this, resolved, false);
|
||||
resolved = Recipient.resolved(resolved.getId());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
|
||||
|
||||
@@ -171,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_WELCOME_PUSH_SCREEN;
|
||||
} else if (SignalStore.storageService().needsAccountRestore()) {
|
||||
return STATE_ENTER_SIGNAL_PIN;
|
||||
} else if (userHasSkippedOrForgottenPin()) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
@@ -190,6 +192,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
|
||||
}
|
||||
|
||||
private boolean userHasSkippedOrForgottenPin() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut() && SignalStore.kbsValues().isPinForgottenOrSkipped();
|
||||
}
|
||||
|
||||
private boolean userMustSetProfileName() {
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||
}
|
||||
|
||||
@@ -39,16 +39,19 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.window.DisplayFeature;
|
||||
import androidx.window.FoldingFeature;
|
||||
import androidx.window.WindowLayoutInfo;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||
@@ -75,16 +78,19 @@ import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
import org.whispersystems.libsignal.IdentityKey;
|
||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||
|
||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
||||
@@ -92,6 +98,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private static final String TAG = Log.tag(WebRtcCallActivity.class);
|
||||
|
||||
private static final int STANDARD_DELAY_FINISH = 1000;
|
||||
private static final int VIBRATE_DURATION = 50;
|
||||
|
||||
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
||||
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
|
||||
@@ -107,10 +114,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private boolean hasWarnedAboutBluetooth;
|
||||
private androidx.window.WindowManager windowManager;
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(@NonNull Context newBase) {
|
||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||
@@ -153,6 +163,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
|
||||
ephemeralStateDisposable = ApplicationDependencies.getSignalCallManager()
|
||||
.ephemeralStates()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(viewModel::updateFromEphemeralState);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
Log.i(TAG, "onResume()");
|
||||
@@ -193,6 +213,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
Log.i(TAG, "onStop");
|
||||
super.onStop();
|
||||
|
||||
ephemeralStateDisposable.dispose();
|
||||
|
||||
if (!isInPipMode() || isFinishing()) {
|
||||
EventBus.getDefault().unregister(this);
|
||||
requestNewSizesThrottle.clear();
|
||||
@@ -285,7 +307,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
|
||||
viewModel = new ViewModelProvider(this, factory).get(WebRtcCallViewModel.class);
|
||||
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||
viewModel.setIsInPipMode(isInPipMode());
|
||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||
@@ -295,7 +317,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
||||
viewModel.getOrientationAndLandscapeEnabled(),
|
||||
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||
viewModel.getEphemeralState(),
|
||||
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||
.observe(this, p -> callScreen.updateCallParticipants(p));
|
||||
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
|
||||
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
|
||||
@@ -501,6 +524,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallReconnecting() {
|
||||
callScreen.setStatus(getString(R.string.WebRtcCallActivity__reconnecting));
|
||||
VibrateUtil.vibrate(this, VIBRATE_DURATION);
|
||||
}
|
||||
|
||||
private void handleRecipientUnavailable() {
|
||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||
callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
|
||||
@@ -623,6 +651,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
handleCallPreJoin(event); break;
|
||||
case CALL_CONNECTED:
|
||||
handleCallConnected(event); break;
|
||||
case CALL_RECONNECTING:
|
||||
handleCallReconnecting(); break;
|
||||
case NETWORK_FAILURE:
|
||||
handleServerFailure(); break;
|
||||
case CALL_RINGING:
|
||||
@@ -659,6 +689,17 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
enableVideoIfAvailable = false;
|
||||
handleSetMuteVideo(false);
|
||||
}
|
||||
|
||||
if (event.getBluetoothPermissionDenied() && !hasWarnedAboutBluetooth && !isFinishing()) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.WebRtcCallActivity__bluetooth_permission_denied)
|
||||
.setMessage(R.string.WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
|
||||
.setPositiveButton(R.string.WebRtcCallActivity__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(this)))
|
||||
.setNegativeButton(R.string.WebRtcCallActivity__not_now, null)
|
||||
.show();
|
||||
|
||||
hasWarnedAboutBluetooth = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||
@@ -807,7 +848,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
if (feature.isPresent()) {
|
||||
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
|
||||
Rect bounds = foldingFeature.getBounds();
|
||||
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
|
||||
if (foldingFeature.isSeparating()) {
|
||||
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
|
||||
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.thoughtcrime.securesms.animation.transitions
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.transition.Transition
|
||||
import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.animation.doOnEnd
|
||||
import androidx.core.animation.doOnStart
|
||||
|
||||
@RequiresApi(21)
|
||||
class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val WIDTH = "CrossfaderTransition.WIDTH"
|
||||
}
|
||||
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is Crossfadeable) {
|
||||
transitionValues.values[WIDTH] = transitionValues.view.width
|
||||
}
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is Crossfadeable) {
|
||||
transitionValues.values[WIDTH] = transitionValues.view.width
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (startValues == null || endValues == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val startWidth = (startValues.values[WIDTH] ?: 0) as Int
|
||||
val endWidth = (endValues.values[WIDTH] ?: 0) as Int
|
||||
val view: Crossfadeable = endValues.view as? Crossfadeable ?: return null
|
||||
val reverse = startWidth > endWidth
|
||||
|
||||
return ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
addUpdateListener {
|
||||
view.onCrossfadeAnimationUpdated(it.animatedValue as Float, reverse)
|
||||
}
|
||||
|
||||
doOnStart {
|
||||
view.onCrossfadeStarted(reverse)
|
||||
}
|
||||
|
||||
doOnEnd {
|
||||
view.onCrossfadeFinished(reverse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Crossfadeable {
|
||||
fun onCrossfadeAnimationUpdated(progress: Float, reverse: Boolean)
|
||||
fun onCrossfadeStarted(reverse: Boolean)
|
||||
fun onCrossfadeFinished(reverse: Boolean)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.animation.transitions
|
||||
|
||||
import android.animation.Animator
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.transition.Transition
|
||||
import android.transition.TransitionValues
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
|
||||
@RequiresApi(21)
|
||||
class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val ELEVATION = "CrossfaderTransition.ELEVATION"
|
||||
}
|
||||
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is FloatingActionButton) {
|
||||
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||
}
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is FloatingActionButton) {
|
||||
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
if (startValues?.view !is FloatingActionButton || endValues?.view !is FloatingActionButton) {
|
||||
return null
|
||||
}
|
||||
|
||||
val startElevation = startValues.view.elevation
|
||||
val endElevation = endValues.view.elevation
|
||||
if (startElevation == endElevation) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ValueAnimator.ofFloat(
|
||||
startValues.values[ELEVATION] as Float,
|
||||
endValues.values[ELEVATION] as Float
|
||||
).apply {
|
||||
addUpdateListener {
|
||||
val elevation = it.animatedValue as Float
|
||||
endValues.view.elevation = elevation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,8 @@ class AvatarView @JvmOverloads constructor(
|
||||
storyRing.visible = true
|
||||
storyRing.isActivated = hasUnreadStory
|
||||
|
||||
avatar.scaleX = 0.82f
|
||||
avatar.scaleY = 0.82f
|
||||
avatar.scaleX = 0.8f
|
||||
avatar.scaleY = 0.8f
|
||||
}
|
||||
|
||||
private fun hideStoryRing() {
|
||||
@@ -51,6 +51,10 @@ class AvatarView @JvmOverloads constructor(
|
||||
avatar.scaleY = 1f
|
||||
}
|
||||
|
||||
fun hasStory(): Boolean {
|
||||
return storyRing.visible
|
||||
}
|
||||
|
||||
fun setStoryRingFromState(storyViewState: StoryViewState) {
|
||||
when (storyViewState) {
|
||||
StoryViewState.NONE -> hideStoryRing()
|
||||
|
||||
@@ -23,6 +23,8 @@ import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
@@ -110,7 +112,7 @@ public class BackupDialog {
|
||||
|
||||
@RequiresApi(29)
|
||||
public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) {
|
||||
new AlertDialog.Builder(fragment.requireContext())
|
||||
new MaterialAlertDialogBuilder(fragment.requireContext())
|
||||
.setView(R.layout.backup_choose_location_dialog)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||
@@ -141,7 +143,7 @@ public class BackupDialog {
|
||||
}
|
||||
|
||||
public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) {
|
||||
new AlertDialog.Builder(context)
|
||||
new MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.BackupDialog_delete_backups)
|
||||
.setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
|
||||
@@ -5,7 +5,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.whispersystems.libsignal.util.ByteUtil;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
@@ -18,6 +18,8 @@ import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.kdf.HKDFv3;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||
@@ -46,13 +48,11 @@ import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
||||
import org.thoughtcrime.securesms.util.SetUtil;
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
||||
import org.whispersystems.libsignal.util.ByteUtil;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
|
||||
@@ -17,6 +17,8 @@ import org.greenrobot.eventbus.EventBus;
|
||||
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.HKDFv3;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
||||
@@ -36,9 +38,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
||||
import org.whispersystems.libsignal.util.ByteUtil;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
@@ -77,7 +77,8 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
"msl_message",
|
||||
"reaction",
|
||||
"notification_profile_schedule",
|
||||
"notification_profile_allowed_members"
|
||||
"notification_profile_allowed_members",
|
||||
"story_sends"
|
||||
};
|
||||
|
||||
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
||||
@@ -96,12 +97,12 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
int count = 0;
|
||||
|
||||
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
|
||||
|
||||
db.beginTransaction();
|
||||
keyValueDatabase.beginTransaction();
|
||||
try {
|
||||
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
|
||||
|
||||
db.beginTransaction();
|
||||
keyValueDatabase.beginTransaction();
|
||||
|
||||
dropAllTables(db);
|
||||
|
||||
BackupFrame frame;
|
||||
@@ -293,6 +294,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
String type = cursor.getString(1);
|
||||
|
||||
if ("table".equals(type) && !name.startsWith("sqlite_")) {
|
||||
Log.i(TAG, "Dropping table: " + name);
|
||||
db.execSQL("DROP TABLE IF EXISTS " + name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,12 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.glide.GiftBadgeModel
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
class BadgeImageView @JvmOverloads constructor(
|
||||
@@ -77,6 +80,20 @@ class BadgeImageView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setGiftBadge(badge: GiftBadge?, glideRequests: GlideRequests) {
|
||||
if (badge != null) {
|
||||
glideRequests
|
||||
.load(GiftBadgeModel(badge))
|
||||
.downsample(DownsampleStrategy.NONE)
|
||||
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), ScreenDensity.getBestDensityBucketForDevice(), ThemeUtil.isDarkTheme(context)))
|
||||
.into(this)
|
||||
} else {
|
||||
glideRequests
|
||||
.clear(this)
|
||||
clearDrawable()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearDrawable() {
|
||||
setImageDrawable(null)
|
||||
isClickable = false
|
||||
|
||||
@@ -9,13 +9,13 @@ import com.google.android.flexbox.FlexboxLayoutManager
|
||||
import com.google.android.flexbox.JustifyContent
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.protocol.util.Pair
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
||||
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||
import org.whispersystems.libsignal.util.Pair
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import java.math.BigDecimal
|
||||
import java.sql.Timestamp
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
/**
|
||||
* Displays expired gift information and gives the user the option to start a recurring monthly donation.
|
||||
*/
|
||||
class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_BADGE = "arg.badge"
|
||||
|
||||
fun show(fragmentManager: FragmentManager, badge: Badge) {
|
||||
ExpiredGiftSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_BADGE, badge)
|
||||
}
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private val badge: Badge
|
||||
get() = requireArguments().getParcelable(ARG_BADGE)!!
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
ExpiredGiftSheetConfiguration.register(adapter)
|
||||
adapter.submitList(
|
||||
configure {
|
||||
forExpiredBadge(
|
||||
badge = badge,
|
||||
onMakeAMonthlyDonation = {
|
||||
requireListener<Callback>().onMakeAMonthlyDonation()
|
||||
},
|
||||
onNotNow = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}.toMappingModelList()
|
||||
)
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMakeAMonthlyDonation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
/**
|
||||
* Contains shared DSL layout for expired gifts, creatable using a GiftBadge or a Badge.
|
||||
*/
|
||||
object ExpiredGiftSheetConfiguration {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
BadgeDisplay112.register(mappingAdapter)
|
||||
}
|
||||
|
||||
fun DSLConfiguration.forExpiredBadge(badge: Badge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||
customPref(BadgeDisplay112.Model(badge, withDisplayText = false))
|
||||
expiredSheet(onMakeAMonthlyDonation, onNotNow)
|
||||
}
|
||||
|
||||
fun DSLConfiguration.forExpiredGiftBadge(giftBadge: GiftBadge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||
customPref(BadgeDisplay112.GiftModel(giftBadge))
|
||||
expiredSheet(onMakeAMonthlyDonation, onNotNow)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.expiredSheet(onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired_and_is,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
stringId = android.R.string.ok
|
||||
),
|
||||
onClick = {
|
||||
onNotNow()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__to_continue,
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__make_a_monthly_donation
|
||||
),
|
||||
onClick = {
|
||||
onMakeAMonthlyDonation()
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(
|
||||
stringId = R.string.ExpiredGiftSheetConfiguration__not_now
|
||||
),
|
||||
onClick = {
|
||||
onNotNow()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||
|
||||
/**
|
||||
* Displays a gift badge sent to or received from a user, and allows the user to
|
||||
* perform an action based off the badge's redemption state.
|
||||
*/
|
||||
class GiftMessageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
init {
|
||||
inflate(context, R.layout.gift_message_view, this)
|
||||
}
|
||||
|
||||
private val badgeView: BadgeImageView = findViewById(R.id.gift_message_view_badge)
|
||||
private val titleView: TextView = findViewById(R.id.gift_message_view_title)
|
||||
private val descriptionView: TextView = findViewById(R.id.gift_message_view_description)
|
||||
private val actionView: MaterialButton = findViewById(R.id.gift_message_view_action)
|
||||
|
||||
init {
|
||||
context.obtainStyledAttributes(attrs, R.styleable.GiftMessageView).use {
|
||||
val textColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__textColor, Color.RED)
|
||||
titleView.setTextColor(textColor)
|
||||
descriptionView.setTextColor(textColor)
|
||||
|
||||
val buttonTextColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonTextColor, Color.RED)
|
||||
actionView.setTextColor(buttonTextColor)
|
||||
actionView.iconTint = ColorStateList.valueOf(buttonTextColor)
|
||||
|
||||
val buttonBackgroundTint = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonBackgroundTint, Color.RED)
|
||||
actionView.backgroundTintList = ColorStateList.valueOf(buttonBackgroundTint)
|
||||
}
|
||||
}
|
||||
|
||||
fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
|
||||
titleView.setText(R.string.GiftMessageView__gift_badge)
|
||||
descriptionView.text = resources.getQuantityString(R.plurals.GiftMessageView__lasts_for_d_months, 1, 1)
|
||||
actionView.icon = null
|
||||
actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
|
||||
actionView.isEnabled = true
|
||||
|
||||
if (isOutgoing) {
|
||||
actionView.setText(R.string.GiftMessageView__view)
|
||||
} else {
|
||||
when (giftBadge.redemptionState) {
|
||||
GiftBadge.RedemptionState.REDEEMED -> {
|
||||
stopAnimationIfNeeded()
|
||||
actionView.setIconResource(R.drawable.ic_check_circle_24)
|
||||
}
|
||||
GiftBadge.RedemptionState.STARTED -> actionView.icon = CircularProgressDrawable(context).apply {
|
||||
actionView.isEnabled = false
|
||||
setColorSchemeColors(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||
strokeWidth = DimensionUnit.DP.toPixels(2f)
|
||||
start()
|
||||
}
|
||||
else -> {
|
||||
stopAnimationIfNeeded()
|
||||
actionView.icon = null
|
||||
}
|
||||
}
|
||||
|
||||
actionView.setText(
|
||||
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
badgeView.setGiftBadge(giftBadge, glideRequests)
|
||||
}
|
||||
|
||||
fun onGiftNotOpened() {
|
||||
actionView.isClickable = false
|
||||
}
|
||||
|
||||
fun onGiftOpened() {
|
||||
actionView.isClickable = true
|
||||
}
|
||||
|
||||
private fun stopAnimationIfNeeded() {
|
||||
val icon = actionView.icon
|
||||
if (icon is CircularProgressDrawable) {
|
||||
icon.stop()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onViewGiftBadgeClicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
|
||||
/**
|
||||
* Helper object for Gift badges
|
||||
*/
|
||||
object Gifts {
|
||||
|
||||
/**
|
||||
* Request Code for getting token from Google Pay
|
||||
*/
|
||||
const val GOOGLE_PAY_REQUEST_CODE = 3000
|
||||
|
||||
/**
|
||||
* Creates an OutgoingSecureMediaMessage which contains the given gift badge.
|
||||
*/
|
||||
fun createOutgoingGiftMessage(
|
||||
recipient: Recipient,
|
||||
giftBadge: GiftBadge,
|
||||
sentTimestamp: Long,
|
||||
expiresIn: Long
|
||||
): OutgoingMediaMessage {
|
||||
return OutgoingSecureMediaMessage(
|
||||
recipient,
|
||||
Base64.encodeBytes(giftBadge.toByteArray()),
|
||||
listOf(),
|
||||
sentTimestamp,
|
||||
ThreadDatabase.DistributionTypes.CONVERSATION,
|
||||
expiresIn,
|
||||
false,
|
||||
StoryType.NONE,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
listOf(),
|
||||
listOf(),
|
||||
listOf(),
|
||||
giftBadge
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
|
||||
/**
|
||||
* Notes that a given item can have a gift box drawn over it.
|
||||
*/
|
||||
interface OpenableGift {
|
||||
/**
|
||||
* Returns a projection to draw a top, or null to not do so.
|
||||
*/
|
||||
fun getOpenableGiftProjection(): Projection?
|
||||
|
||||
/**
|
||||
* Returns a unique id assosicated with this gift.
|
||||
*/
|
||||
fun getGiftId(): Long
|
||||
|
||||
/**
|
||||
* Registers a callback to start the open animation
|
||||
*/
|
||||
fun setOpenGiftCallback(openGift: (OpenableGift) -> Unit)
|
||||
|
||||
/**
|
||||
* Clears any callback created to start the open animation
|
||||
*/
|
||||
fun clearOpenGiftCallback()
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts
|
||||
|
||||
import android.animation.FloatEvaluator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.provider.Settings
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.AnticipateInterpolator
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.toRect
|
||||
import androidx.core.graphics.withSave
|
||||
import androidx.core.graphics.withTranslation
|
||||
import androidx.core.view.children
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.Projection
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Controls the gift box top and related animations for Gift bubbles.
|
||||
*/
|
||||
class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
|
||||
|
||||
private val animatorDurationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
|
||||
private val messageIdsShakenThisSession = mutableSetOf<Long>()
|
||||
private val messageIdsOpenedThisSession = mutableSetOf<Long>()
|
||||
private val animationState = mutableMapOf<Long, GiftAnimationState>()
|
||||
|
||||
private val rect = RectF()
|
||||
private val lineWidth = DimensionUnit.DP.toPixels(16f).toInt()
|
||||
|
||||
private val boxPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = ContextCompat.getColor(context, R.color.core_ultramarine)
|
||||
}
|
||||
|
||||
private val bowPaint = Paint().apply {
|
||||
isAntiAlias = true
|
||||
color = Color.WHITE
|
||||
}
|
||||
|
||||
private val bowWidth = DimensionUnit.DP.toPixels(80f)
|
||||
private val bowHeight = DimensionUnit.DP.toPixels(60f)
|
||||
private val bowDrawable: Drawable = AppCompatResources.getDrawable(context, R.drawable.ic_gift_bow)!!
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
super.onDestroy(owner)
|
||||
animationState.clear()
|
||||
}
|
||||
|
||||
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
var needsInvalidation = false
|
||||
val openableChildren = parent.children.filterIsInstance(OpenableGift::class.java)
|
||||
|
||||
val deadKeys = animationState.keys.filterNot { giftId -> openableChildren.any { it.getGiftId() == giftId } }
|
||||
deadKeys.forEach {
|
||||
animationState.remove(it)
|
||||
}
|
||||
|
||||
val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
|
||||
|
||||
notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
|
||||
val projection = child.getOpenableGiftProjection()
|
||||
if (projection != null) {
|
||||
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
|
||||
child.setOpenGiftCallback {
|
||||
child.clearOpenGiftCallback()
|
||||
val proj = it.getOpenableGiftProjection()
|
||||
if (proj != null) {
|
||||
messageIdsOpenedThisSession.add(it.getGiftId())
|
||||
startOpenAnimation(it)
|
||||
parent.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
drawGiftBox(c, projection)
|
||||
drawGiftBow(c, projection)
|
||||
} else {
|
||||
messageIdsShakenThisSession.add(child.getGiftId())
|
||||
startShakeAnimation(child)
|
||||
|
||||
drawGiftBox(c, projection)
|
||||
drawGiftBow(c, projection)
|
||||
|
||||
needsInvalidation = true
|
||||
}
|
||||
|
||||
projection.release()
|
||||
}
|
||||
}
|
||||
|
||||
openableChildren.filter { animationState.containsKey(it.getGiftId()) }.forEach { child ->
|
||||
val runningAnimation = animationState[child.getGiftId()]!!
|
||||
c.withSave {
|
||||
val isThisAnimationRunning = runningAnimation.update(
|
||||
animatorDurationScale = animatorDurationScale,
|
||||
canvas = c,
|
||||
drawBox = this@OpenableGiftItemDecoration::drawGiftBox,
|
||||
drawBow = this@OpenableGiftItemDecoration::drawGiftBow
|
||||
)
|
||||
|
||||
if (!isThisAnimationRunning) {
|
||||
animationState.remove(child.getGiftId())
|
||||
}
|
||||
|
||||
needsInvalidation = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsInvalidation) {
|
||||
parent.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawGiftBox(canvas: Canvas, projection: Projection) {
|
||||
canvas.drawPath(projection.path, boxPaint)
|
||||
|
||||
rect.set(
|
||||
projection.x + (projection.width / 2) - lineWidth / 2,
|
||||
projection.y,
|
||||
projection.x + (projection.width / 2) + lineWidth / 2,
|
||||
projection.y + projection.height
|
||||
)
|
||||
|
||||
canvas.drawRect(rect, bowPaint)
|
||||
|
||||
rect.set(
|
||||
projection.x,
|
||||
projection.y + (projection.height / 2) - lineWidth / 2,
|
||||
projection.x + projection.width,
|
||||
projection.y + (projection.height / 2) + lineWidth / 2
|
||||
)
|
||||
|
||||
canvas.drawRect(rect, bowPaint)
|
||||
}
|
||||
|
||||
private fun drawGiftBow(canvas: Canvas, projection: Projection) {
|
||||
rect.set(
|
||||
projection.x + (projection.width / 2) - (bowWidth / 2),
|
||||
projection.y,
|
||||
projection.x + (projection.width / 2) + (bowWidth / 2),
|
||||
projection.y + bowHeight
|
||||
)
|
||||
|
||||
val padTop = (projection.height - rect.height()) * (48f / 89f)
|
||||
|
||||
bowDrawable.bounds = rect.toRect()
|
||||
canvas.withTranslation(y = padTop) {
|
||||
bowDrawable.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startShakeAnimation(child: OpenableGift) {
|
||||
animationState[child.getGiftId()] = GiftAnimationState.ShakeAnimationState(child, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun startOpenAnimation(child: OpenableGift) {
|
||||
animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long, val duration: Long) {
|
||||
|
||||
/**
|
||||
* Shakes the gift box to the left and right, slightly revealing the contents underneath.
|
||||
* Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
|
||||
* following behind.
|
||||
*/
|
||||
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, SHAKE_DURATION_MILLIS) {
|
||||
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
|
||||
canvas.withTranslation(x = getTranslation(progress).toFloat()) {
|
||||
drawBox(canvas, projection)
|
||||
}
|
||||
|
||||
canvas.withTranslation(x = getTranslation(lastFrameProgress).toFloat()) {
|
||||
drawBow(canvas, projection)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTranslation(progress: Float): Double {
|
||||
val interpolated = INTERPOLATOR.getInterpolation(progress)
|
||||
val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f)
|
||||
|
||||
return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI
|
||||
}
|
||||
}
|
||||
|
||||
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, OPEN_DURATION_MILLIS) {
|
||||
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
|
||||
val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
|
||||
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(161f))
|
||||
|
||||
val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(progress)
|
||||
val evaluatedY = EVALUATOR.evaluate(interpolatedY, 0f, DimensionUnit.DP.toPixels(355f))
|
||||
|
||||
canvas.translate(evaluatedValue, evaluatedY)
|
||||
|
||||
drawBox(canvas, projection)
|
||||
drawBow(canvas, projection)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
|
||||
val projection = openableGift.getOpenableGiftProjection() ?: return false
|
||||
|
||||
if (animatorDurationScale <= 0f) {
|
||||
update(canvas, projection, 0f, 0f, drawBox, drawBow)
|
||||
projection.release()
|
||||
return false
|
||||
}
|
||||
|
||||
val currentFrameTime = System.currentTimeMillis()
|
||||
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (duration.toFloat() * animatorDurationScale))
|
||||
val progress = (currentFrameTime - startTime) / (duration.toFloat() * animatorDurationScale)
|
||||
|
||||
if (progress > 1f) {
|
||||
update(canvas, projection, 1f, 1f, drawBox, drawBow)
|
||||
projection.release()
|
||||
return false
|
||||
}
|
||||
|
||||
update(canvas, projection, progress, lastFrameProgress, drawBox, drawBow)
|
||||
projection.release()
|
||||
return true
|
||||
}
|
||||
|
||||
protected abstract fun update(
|
||||
canvas: Canvas,
|
||||
projection: Projection,
|
||||
progress: Float,
|
||||
lastFrameProgress: Float,
|
||||
drawBox: (Canvas, Projection) -> Unit,
|
||||
drawBow: (Canvas, Projection) -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TRANSLATION_Y_INTERPOLATOR = AnticipateInterpolator(3f)
|
||||
private val INTERPOLATOR = AccelerateDecelerateInterpolator()
|
||||
private val EVALUATOR = FloatEvaluator()
|
||||
|
||||
private const val SHAKE_DURATION_MILLIS = 1000L
|
||||
private const val OPEN_DURATION_MILLIS = 700L
|
||||
private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
|
||||
/**
|
||||
* Convenience wrapper for a gift at a particular price point.
|
||||
*/
|
||||
data class Gift(val level: Long, val price: FiatMoney)
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.NavHostFragment
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
|
||||
/**
|
||||
* Activity which houses the gift flow.
|
||||
*/
|
||||
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
onBackPressedDispatcher.addCallback(this, OnBackPressed())
|
||||
}
|
||||
|
||||
override fun getFragment(): Fragment {
|
||||
return NavHostFragment.create(R.navigation.gift_flow)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
|
||||
}
|
||||
|
||||
private inner class OnBackPressed : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (!findNavController(R.id.fragment_container).popBackStack()) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.view.KeyEvent
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.components.settings.models.TextInput
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||
import org.thoughtcrime.securesms.util.Debouncer
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
/**
|
||||
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
|
||||
*/
|
||||
class GiftFlowConfirmationFragment :
|
||||
DSLSettingsFragment(
|
||||
titleId = R.string.GiftFlowConfirmationFragment__confirm_gift,
|
||||
layoutId = R.layout.gift_flow_confirmation_fragment
|
||||
),
|
||||
EmojiKeyboardPageFragment.Callback,
|
||||
EmojiEventListener,
|
||||
EmojiSearchFragment.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
private lateinit var inputAwareLayout: InputAwareLayout
|
||||
private lateinit var emojiKeyboard: MediaKeyboard
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private var errorDialog: DialogInterface? = null
|
||||
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
|
||||
private lateinit var donationPaymentComponent: DonationPaymentComponent
|
||||
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
|
||||
|
||||
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
|
||||
private val debouncer = Debouncer(100L)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
RecipientPreference.register(adapter)
|
||||
GiftRowItem.register(adapter)
|
||||
|
||||
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||
|
||||
donationPaymentComponent = requireListener()
|
||||
|
||||
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.processing_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
verifyingRecipientDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.verifying_recipient_payment_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
|
||||
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
|
||||
|
||||
emojiKeyboard.setFragmentManager(childFragmentManager)
|
||||
|
||||
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button)
|
||||
googlePayButton.setOnGooglePayClickListener {
|
||||
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
|
||||
}
|
||||
|
||||
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
|
||||
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
|
||||
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
|
||||
textInputViewHolder.onAttachedToWindow()
|
||||
|
||||
inputAwareLayout.addOnKeyboardShownListener {
|
||||
inputAwareLayout.hideAttachedInput(true)
|
||||
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||
}
|
||||
|
||||
requireActivity().onBackPressedDispatcher.addCallback(
|
||||
viewLifecycleOwner,
|
||||
object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (inputAwareLayout.isInputOpen) {
|
||||
inputAwareLayout.hideAttachedInput(true)
|
||||
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||
} else {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
|
||||
if (state.stage == GiftFlowState.Stage.RECIPIENT_VERIFICATION) {
|
||||
debouncer.publish { verifyingRecipientDonationPaymentDialog.show() }
|
||||
} else {
|
||||
debouncer.clear()
|
||||
verifyingRecipientDonationPaymentDialog.dismiss()
|
||||
}
|
||||
|
||||
if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
|
||||
processingDonationPaymentDialog.show()
|
||||
} else {
|
||||
processingDonationPaymentDialog.dismiss()
|
||||
}
|
||||
|
||||
textInputViewHolder.bind(
|
||||
TextInput.MultilineModel(
|
||||
text = state.additionalMessage,
|
||||
hint = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__add_a_message),
|
||||
onTextChanged = {
|
||||
viewModel.setAdditionalMessage(it)
|
||||
},
|
||||
onEmojiToggleClicked = {
|
||||
if (inputAwareLayout.isKeyboardOpen || (!inputAwareLayout.isKeyboardOpen && !inputAwareLayout.isInputOpen)) {
|
||||
inputAwareLayout.show(it, emojiKeyboard)
|
||||
emojiToggle.setImageResource(R.drawable.ic_keyboard_24)
|
||||
} else {
|
||||
inputAwareLayout.showSoftkey(it)
|
||||
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
|
||||
lifecycleDisposable += DonationError
|
||||
.getErrorsForSource(DonationErrorSource.GIFT)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { donationError ->
|
||||
onPaymentError(donationError)
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
|
||||
when (donationEvent) {
|
||||
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
|
||||
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
|
||||
DonationEvent.SubscriptionCancelled -> Unit
|
||||
is DonationEvent.SubscriptionCancellationFailed -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
|
||||
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
textInputViewHolder.onDetachedFromWindow()
|
||||
processingDonationPaymentDialog.dismiss()
|
||||
debouncer.clear()
|
||||
verifyingRecipientDonationPaymentDialog.dismiss()
|
||||
}
|
||||
|
||||
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
|
||||
return configure {
|
||||
if (giftFlowState.giftBadge != null) {
|
||||
giftFlowState.giftPrices[giftFlowState.currency]?.let {
|
||||
customPref(
|
||||
GiftRowItem.Model(
|
||||
giftBadge = giftFlowState.giftBadge,
|
||||
price = it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sectionHeaderPref(R.string.GiftFlowConfirmationFragment__send_to)
|
||||
|
||||
customPref(
|
||||
RecipientPreference.Model(
|
||||
recipient = giftFlowState.recipient!!
|
||||
)
|
||||
)
|
||||
|
||||
textPref(
|
||||
summary = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__your_gift_will_be_sent_in)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onPaymentConfirmed() {
|
||||
val mainActivityIntent = MainActivity.clearTop(requireContext())
|
||||
val conversationIntent = ConversationIntents
|
||||
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
|
||||
.withGiftBadge(viewModel.snapshot.giftBadge!!)
|
||||
.build()
|
||||
|
||||
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
|
||||
}
|
||||
|
||||
private fun onPaymentError(throwable: Throwable?) {
|
||||
Log.w(TAG, "onPaymentError", throwable, true)
|
||||
|
||||
if (errorDialog != null) {
|
||||
Log.i(TAG, "Already displaying an error dialog. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
requireContext(), throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
override fun onDialogDismissed() {
|
||||
requireActivity().finish()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun openEmojiSearch() {
|
||||
emojiKeyboard.onOpenEmojiSearch()
|
||||
}
|
||||
|
||||
override fun closeEmojiSearch() {
|
||||
emojiKeyboard.onCloseEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(emoji: String?) {
|
||||
if (emoji?.isNotEmpty() == true) {
|
||||
eventPublisher.onNext(TextInput.TextInputEvent.OnEmojiEvent(emoji))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||
if (keyEvent != null) {
|
||||
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Allows the user to select a recipient to send a gift to.
|
||||
*/
|
||||
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() }
|
||||
)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(
|
||||
R.id.multiselect_container,
|
||||
MultiselectForwardFragment.create(
|
||||
MultiselectForwardFragmentArgs(
|
||||
canSendToNonPush = false,
|
||||
multiShareArgs = emptyList(),
|
||||
forceDisableAddMessage = true,
|
||||
selectSingleRecipient = true
|
||||
)
|
||||
)
|
||||
)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration {
|
||||
return ContactSearchConfiguration.build {
|
||||
query = contactSearchState.query
|
||||
|
||||
if (query.isNullOrEmpty()) {
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Recents(
|
||||
includeHeader = true,
|
||||
mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
addSection(
|
||||
ContactSearchConfiguration.Section.Individuals(
|
||||
includeSelf = false,
|
||||
transportType = ContactSearchConfiguration.TransportType.PUSH,
|
||||
includeHeader = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinishForwardAction() = Unit
|
||||
|
||||
override fun exitFlow() = Unit
|
||||
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
|
||||
|
||||
if (contacts.isNotEmpty()) {
|
||||
viewModel.setSelectedContact(contacts.first())
|
||||
findNavController().safeNavigate(R.id.action_giftFlowRecipientSelectionFragment_to_giftFlowConfirmationFragment)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getContainer(): ViewGroup = requireView() as ViewGroup
|
||||
|
||||
override fun getDialogBackgroundColor(): Int = Color.TRANSPARENT
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Repository for grabbing gift badges and supported currency information.
|
||||
*/
|
||||
class GiftFlowRepository {
|
||||
|
||||
fun getGiftBadge(): Single<Pair<Long, Badge>> {
|
||||
return ApplicationDependencies.getDonationsService()
|
||||
.getGiftBadges(Locale.getDefault())
|
||||
.flatMap(ServiceResponse<Map<Long, SignalServiceProfile.Badge>>::flattenResult)
|
||||
.map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } }
|
||||
.map { it.first() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getGiftPricing(): Single<Map<Currency, FiatMoney>> {
|
||||
return ApplicationDependencies.getDonationsService()
|
||||
.giftAmount
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { result ->
|
||||
result
|
||||
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
|
||||
.mapKeys { (code, _) -> Currency.getInstance(code) }
|
||||
.mapValues { (currency, price) -> FiatMoney(price, currency) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
* Landing fragment for sending gifts.
|
||||
*/
|
||||
class GiftFlowStartFragment : DSLSettingsFragment(
|
||||
layoutId = R.layout.gift_flow_start_fragment
|
||||
) {
|
||||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() },
|
||||
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
CurrencySelection.register(adapter)
|
||||
GiftRowItem.register(adapter)
|
||||
NetworkFailure.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
|
||||
val next = requireView().findViewById<View>(R.id.next)
|
||||
next.setOnClickListener {
|
||||
findNavController().safeNavigate(R.id.action_giftFlowStartFragment_to_giftFlowRecipientSelectionFragment)
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||
next.isEnabled = state.stage == GiftFlowState.Stage.READY
|
||||
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: GiftFlowState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
selectedCurrency = state.currency,
|
||||
isEnabled = state.stage == GiftFlowState.Stage.READY,
|
||||
onClick = {
|
||||
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())
|
||||
findNavController().safeNavigate(action)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
if (state.stage == GiftFlowState.Stage.FAILURE) {
|
||||
customPref(
|
||||
NetworkFailure.Model(
|
||||
onRetryClick = {
|
||||
viewModel.retry()
|
||||
}
|
||||
)
|
||||
)
|
||||
} else if (state.stage == GiftFlowState.Stage.INIT) {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
} else if (state.giftBadge != null) {
|
||||
state.giftPrices[state.currency]?.let {
|
||||
customPref(
|
||||
GiftRowItem.Model(
|
||||
giftBadge = state.giftBadge,
|
||||
price = it
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* State maintained by the GiftFlowViewModel
|
||||
*/
|
||||
data class GiftFlowState(
|
||||
val currency: Currency,
|
||||
val giftLevel: Long? = null,
|
||||
val giftBadge: Badge? = null,
|
||||
val giftPrices: Map<Currency, FiatMoney> = emptyMap(),
|
||||
val stage: Stage = Stage.INIT,
|
||||
val recipient: Recipient? = null,
|
||||
val additionalMessage: CharSequence? = null
|
||||
) {
|
||||
enum class Stage {
|
||||
INIT,
|
||||
READY,
|
||||
RECIPIENT_VERIFICATION,
|
||||
TOKEN_REQUEST,
|
||||
PAYMENT_PIPELINE,
|
||||
FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import com.google.android.gms.wallet.PaymentData
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.signal.donations.GooglePayApi
|
||||
import org.thoughtcrime.securesms.badges.gifts.Gifts
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import java.util.Currency
|
||||
|
||||
/**
|
||||
* Maintains state as a user works their way through the gift flow.
|
||||
*/
|
||||
class GiftFlowViewModel(
|
||||
val repository: GiftFlowRepository,
|
||||
val donationPaymentRepository: DonationPaymentRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var giftToPurchase: Gift? = null
|
||||
|
||||
private val store = RxStore(
|
||||
GiftFlowState(
|
||||
currency = SignalStore.donationsValues().getOneTimeCurrency()
|
||||
)
|
||||
)
|
||||
private val disposables = CompositeDisposable()
|
||||
private val eventPublisher: PublishSubject<DonationEvent> = PublishSubject.create()
|
||||
private val networkDisposable: Disposable
|
||||
|
||||
val state: Flowable<GiftFlowState> = store.stateFlowable
|
||||
val events: Observable<DonationEvent> = eventPublisher
|
||||
val snapshot: GiftFlowState get() = store.state
|
||||
|
||||
init {
|
||||
refresh()
|
||||
|
||||
networkDisposable = InternetConnectionObserver
|
||||
.observe()
|
||||
.distinctUntilChanged()
|
||||
.subscribe { isConnected ->
|
||||
if (isConnected) {
|
||||
retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun retry() {
|
||||
if (!disposables.isDisposed && store.state.stage == GiftFlowState.Stage.FAILURE) {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.INIT) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
disposables.clear()
|
||||
disposables += SignalStore.donationsValues().observableOneTimeCurrency.subscribe { currency ->
|
||||
store.update {
|
||||
it.copy(
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftPricing().subscribe { giftPrices ->
|
||||
store.update {
|
||||
it.copy(
|
||||
giftPrices = giftPrices,
|
||||
stage = getLoadState(it, giftPrices = giftPrices)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftBadge().subscribeBy(
|
||||
onSuccess = { (giftLevel, giftBadge) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
giftLevel = giftLevel,
|
||||
giftBadge = giftBadge,
|
||||
stage = getLoadState(it, giftBadge = giftBadge)
|
||||
)
|
||||
}
|
||||
},
|
||||
onError = { throwable ->
|
||||
Log.w(TAG, "Could not load gift badge", throwable)
|
||||
store.update {
|
||||
it.copy(
|
||||
stage = GiftFlowState.Stage.FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun setSelectedContact(selectedContact: ContactSearchKey.RecipientSearchKey) {
|
||||
store.update {
|
||||
it.copy(recipient = Recipient.resolved(selectedContact.recipientId))
|
||||
}
|
||||
}
|
||||
|
||||
fun getSupportedCurrencyCodes(): List<String> {
|
||||
return store.state.giftPrices.keys.map { it.currencyCode }
|
||||
}
|
||||
|
||||
fun requestTokenFromGooglePay(label: String) {
|
||||
val giftLevel = store.state.giftLevel ?: return
|
||||
val giftPrice = store.state.giftPrices[store.state.currency] ?: return
|
||||
val giftRecipient = store.state.recipient?.id ?: return
|
||||
|
||||
this.giftToPurchase = Gift(giftLevel, giftPrice)
|
||||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
|
||||
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.TOKEN_REQUEST) }
|
||||
donationPaymentRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
|
||||
},
|
||||
onError = this::onPaymentFlowError
|
||||
)
|
||||
}
|
||||
|
||||
fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
data: Intent?
|
||||
) {
|
||||
val gift = giftToPurchase
|
||||
giftToPurchase = null
|
||||
|
||||
val recipient = store.state.recipient?.id
|
||||
|
||||
donationPaymentRepository.onActivityResult(
|
||||
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
if (gift != null && recipient != null) {
|
||||
eventPublisher.onNext(DonationEvent.RequestTokenSuccess)
|
||||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
|
||||
onError = this@GiftFlowViewModel::onPaymentFlowError,
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
eventPublisher.onNext(DonationEvent.PaymentConfirmationSuccess(store.state.giftBadge!!))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(googlePayException: GooglePayApi.GooglePayException) {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), DonationError.getGooglePayRequestTokenError(DonationErrorSource.GIFT, googlePayException))
|
||||
}
|
||||
|
||||
override fun onCancelled() {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPaymentFlowError(throwable: Throwable) {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
val donationError: DonationError = if (throwable is DonationError) {
|
||||
throwable
|
||||
} else {
|
||||
Log.w(TAG, "Failed to complete payment or redemption", throwable, true)
|
||||
DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT)
|
||||
}
|
||||
DonationError.routeDonationError(ApplicationDependencies.getApplication(), donationError)
|
||||
}
|
||||
|
||||
private fun getLoadState(
|
||||
oldState: GiftFlowState,
|
||||
giftPrices: Map<Currency, FiatMoney>? = null,
|
||||
giftBadge: Badge? = null,
|
||||
): GiftFlowState.Stage {
|
||||
if (oldState.stage != GiftFlowState.Stage.INIT) {
|
||||
return oldState.stage
|
||||
}
|
||||
|
||||
if (giftPrices?.isNotEmpty() == true) {
|
||||
return if (oldState.giftBadge != null) {
|
||||
GiftFlowState.Stage.READY
|
||||
} else {
|
||||
GiftFlowState.Stage.INIT
|
||||
}
|
||||
}
|
||||
|
||||
if (giftBadge != null) {
|
||||
return if (oldState.giftPrices.isNotEmpty()) {
|
||||
GiftFlowState.Stage.READY
|
||||
} else {
|
||||
GiftFlowState.Stage.INIT
|
||||
}
|
||||
}
|
||||
|
||||
return GiftFlowState.Stage.INIT
|
||||
}
|
||||
|
||||
fun setAdditionalMessage(additionalMessage: CharSequence) {
|
||||
store.update { it.copy(additionalMessage = additionalMessage) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftFlowViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val repository: GiftFlowRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(
|
||||
GiftFlowViewModel(
|
||||
repository,
|
||||
donationPaymentRepository
|
||||
)
|
||||
) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* A line item for gifts, displayed in the Gift flow's start and confirmation fragments.
|
||||
*/
|
||||
object GiftRowItem {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.subscription_preference))
|
||||
}
|
||||
|
||||
class Model(val giftBadge: Badge, val price: FiatMoney) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = giftBadge.id == newItem.giftBadge.id
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = giftBadge == newItem.giftBadge && price == newItem.price
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val badgeView = itemView.findViewById<BadgeImageView>(R.id.badge)
|
||||
private val titleView = itemView.findViewById<TextView>(R.id.title)
|
||||
private val checkView = itemView.findViewById<View>(R.id.check)
|
||||
private val taglineView = itemView.findViewById<TextView>(R.id.tagline)
|
||||
private val priceView = itemView.findViewById<TextView>(R.id.price)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
checkView.visible = false
|
||||
badgeView.setBadge(model.giftBadge)
|
||||
titleView.text = model.giftBadge.name
|
||||
taglineView.setText(R.string.GiftRowItem__send_a_gift_badge)
|
||||
priceView.text = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
model.price,
|
||||
FiatMoneyUtil.formatOptions()
|
||||
.trimZerosAfterDecimal()
|
||||
.withDisplayTime(false)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.widget.FrameLayout
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Wraps the google pay button in a convenient frame layout.
|
||||
*/
|
||||
class GooglePayButton @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : FrameLayout(context, attrs) {
|
||||
init {
|
||||
inflate(context, R.layout.donate_with_googlepay_button, this)
|
||||
}
|
||||
|
||||
fun setOnGooglePayClickListener(action: () -> Unit) {
|
||||
getChildAt(0).setOnClickListener { action() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.thanks
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgePreview
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Displays a "Thank you" message in a conversation when redirected
|
||||
* there after purchasing and sending a gift badge.
|
||||
*/
|
||||
class GiftThanksSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
private const val ARGS_RECIPIENT_ID = "args.recipient.id"
|
||||
private const val ARGS_BADGE = "args.badge"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, recipientId: RecipientId, badge: Badge) {
|
||||
GiftThanksSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARGS_RECIPIENT_ID, recipientId)
|
||||
putParcelable(ARGS_BADGE, badge)
|
||||
}
|
||||
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val recipientId: RecipientId
|
||||
get() = requireArguments().getParcelable(ARGS_RECIPIENT_ID)!!
|
||||
|
||||
private val badge: Badge
|
||||
get() = requireArguments().getParcelable(ARGS_BADGE)!!
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgePreview.register(adapter)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
lifecycleDisposable += Recipient.observable(recipientId).observeOn(AndroidSchedulers.mainThread()).subscribe {
|
||||
adapter.submitList(getConfiguration(it).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(recipient: Recipient): DSLConfiguration {
|
||||
return configure {
|
||||
textPref(
|
||||
title = DSLSettingsText.from(R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__thanks_for_your_support, DSLSettingsText.Title2BoldModifier, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(getString(R.string.GiftThanksSheet__youve_gifted_a_badge_to_s, recipient.getDisplayName(requireContext())))
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(37f).toInt())
|
||||
|
||||
customPref(
|
||||
BadgePreview.BadgeModel.GiftedBadgeModel(
|
||||
badge = badge,
|
||||
recipient = recipient
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(60f).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift
|
||||
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Shared repository for getting information about a particular gift.
|
||||
*/
|
||||
class ViewGiftRepository {
|
||||
fun getBadge(giftBadge: GiftBadge): Single<Badge> {
|
||||
val presentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
|
||||
return ApplicationDependencies
|
||||
.getDonationsService()
|
||||
.getGiftBadge(Locale.getDefault(), presentation.receiptLevel)
|
||||
.flatMap { it.flattenResult() }
|
||||
.map { Badges.fromServiceBadge(it) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getGiftBadge(messageId: Long): Observable<GiftBadge> {
|
||||
return Observable.create { emitter ->
|
||||
fun refresh() {
|
||||
val record = SignalDatabase.mms.getMessageRecord(messageId)
|
||||
val giftBadge: GiftBadge = (record as MmsMessageRecord).giftBadge!!
|
||||
|
||||
emitter.onNext(giftBadge)
|
||||
}
|
||||
|
||||
val messageObserver = DatabaseObserver.MessageObserver {
|
||||
if (it.mms && messageId == it.id) {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver)
|
||||
emitter.setCancellable {
|
||||
ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver)
|
||||
}
|
||||
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredGiftBadge
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay160
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||
import org.thoughtcrime.securesms.components.settings.models.OutlinedSwitch
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Handles all interactions for received gift badges.
|
||||
*/
|
||||
class ViewReceivedGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ViewReceivedGiftBottomSheet::class.java)
|
||||
|
||||
private const val ARG_GIFT_BADGE = "arg.gift.badge"
|
||||
private const val ARG_SENT_FROM = "arg.sent.from"
|
||||
private const val ARG_MESSAGE_ID = "arg.message.id"
|
||||
|
||||
@JvmField
|
||||
val REQUEST_KEY: String = TAG
|
||||
|
||||
const val RESULT_NOT_NOW = "result.not.now"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
|
||||
ViewReceivedGiftBottomSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_SENT_FROM, messageRecord.recipient.id)
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
|
||||
putLong(ARG_MESSAGE_ID, messageRecord.id)
|
||||
}
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val sentFrom: RecipientId
|
||||
get() = requireArguments().getParcelable(ARG_SENT_FROM)!!
|
||||
|
||||
private val messageId: Long
|
||||
get() = requireArguments().getLong(ARG_MESSAGE_ID)
|
||||
|
||||
private val viewModel: ViewReceivedGiftViewModel by viewModels(
|
||||
factoryProducer = { ViewReceivedGiftViewModel.Factory(sentFrom, messageId, ViewGiftRepository(), BadgeRepository(requireContext())) }
|
||||
)
|
||||
|
||||
private var errorDialog: DialogInterface? = null
|
||||
private lateinit var progressDialog: AlertDialog
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgeDisplay112.register(adapter)
|
||||
OutlinedSwitch.register(adapter)
|
||||
BadgeDisplay160.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
|
||||
progressDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setView(R.layout.redeeming_gift_dialog)
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
lifecycleDisposable += DonationError
|
||||
.getErrorsForSource(DonationErrorSource.GIFT_REDEMPTION)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { donationError ->
|
||||
onRedemptionError(donationError)
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
progressDialog.hide()
|
||||
}
|
||||
|
||||
private fun onRedemptionError(throwable: Throwable?) {
|
||||
Log.w(TAG, "onRedemptionError", throwable, true)
|
||||
|
||||
if (errorDialog != null) {
|
||||
Log.i(TAG, "Already displaying an error dialog. Skipping.")
|
||||
return
|
||||
}
|
||||
|
||||
errorDialog = DonationErrorDialogs.show(
|
||||
requireContext(), throwable,
|
||||
object : DonationErrorDialogs.DialogCallback() {
|
||||
override fun onDialogDismissed() {
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ViewReceivedGiftState): DSLConfiguration {
|
||||
return configure {
|
||||
if (state.giftBadge == null) {
|
||||
customPref(IndeterminateLoadingCircle)
|
||||
} else if (isGiftBadgeExpired(state.giftBadge)) {
|
||||
forExpiredGiftBadge(
|
||||
giftBadge = state.giftBadge,
|
||||
onMakeAMonthlyDonation = {
|
||||
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
requireActivity().finish()
|
||||
},
|
||||
onNotNow = {
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
if (state.giftBadge.redemptionState == GiftBadge.RedemptionState.STARTED) {
|
||||
progressDialog.show()
|
||||
} else {
|
||||
progressDialog.hide()
|
||||
}
|
||||
|
||||
if (state.recipient != null && !isGiftBadgeRedeemed(state.giftBadge)) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__s_sent_you_a_gift, state.recipient.getShortDisplayName(requireContext())),
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(12f).toInt())
|
||||
presentSubheading(state.recipient)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(37f).toInt())
|
||||
}
|
||||
|
||||
if (state.badge != null && state.controlState != null) {
|
||||
presentForUnexpiredGiftBadge(state, state.giftBadge, state.controlState, state.badge)
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentSubheading(recipient: Recipient) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
charSequence = requireContext().getString(R.string.ViewReceivedGiftBottomSheet__youve_received_a_gift_badge, recipient.getDisplayName(requireContext())),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun DSLConfiguration.presentForUnexpiredGiftBadge(
|
||||
state: ViewReceivedGiftState,
|
||||
giftBadge: GiftBadge,
|
||||
controlState: ViewReceivedGiftState.ControlState,
|
||||
badge: Badge
|
||||
) {
|
||||
when (giftBadge.redemptionState) {
|
||||
GiftBadge.RedemptionState.REDEEMED -> {
|
||||
customPref(
|
||||
BadgeDisplay160.Model(
|
||||
badge = badge
|
||||
)
|
||||
)
|
||||
|
||||
state.recipient?.run {
|
||||
presentSubheading(this)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
customPref(
|
||||
BadgeDisplay112.Model(
|
||||
badge = badge
|
||||
)
|
||||
)
|
||||
|
||||
customPref(
|
||||
OutlinedSwitch.Model(
|
||||
text = DSLSettingsText.from(
|
||||
when (controlState) {
|
||||
ViewReceivedGiftState.ControlState.DISPLAY -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__display_on_profile
|
||||
ViewReceivedGiftState.ControlState.FEATURE -> R.string.SubscribeThanksForYourSupportBottomSheetDialogFragment__make_featured_badge
|
||||
}
|
||||
),
|
||||
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
|
||||
isChecked = state.getControlChecked(),
|
||||
onClick = {
|
||||
viewModel.setChecked(!it.isChecked)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if (state.hasOtherBadges && state.displayingOtherBadges) {
|
||||
noPadTextPref(DSLSettingsText.from(R.string.ThanksForYourSupportBottomSheetFragment__when_you_have_more))
|
||||
}
|
||||
|
||||
space(DimensionUnit.DP.toPixels(36f).toInt())
|
||||
|
||||
primaryButton(
|
||||
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__redeem),
|
||||
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.redeem().subscribeBy(
|
||||
onComplete = {
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onError = {
|
||||
onRedemptionError(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
secondaryButtonNoOutline(
|
||||
text = DSLSettingsText.from(R.string.ViewReceivedGiftSheet__not_now),
|
||||
isEnabled = giftBadge.redemptionState != GiftBadge.RedemptionState.STARTED,
|
||||
onClick = {
|
||||
setFragmentResult(
|
||||
REQUEST_KEY,
|
||||
Bundle().apply {
|
||||
putBoolean(RESULT_NOT_NOW, true)
|
||||
}
|
||||
)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGiftBadgeRedeemed(giftBadge: GiftBadge): Boolean {
|
||||
return giftBadge.redemptionState == GiftBadge.RedemptionState.REDEEMED
|
||||
}
|
||||
|
||||
private fun isGiftBadgeExpired(giftBadge: GiftBadge): Boolean {
|
||||
return try {
|
||||
val receiptCredentialPresentation = ReceiptCredentialPresentation(giftBadge.redemptionToken.toByteArray())
|
||||
|
||||
receiptCredentialPresentation.receiptExpirationTime <= TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis())
|
||||
} catch (e: InvalidInputException) {
|
||||
Log.w(TAG, "Failed to check expiration of given badge.", e)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ViewReceivedGiftState(
|
||||
val recipient: Recipient? = null,
|
||||
val giftBadge: GiftBadge? = null,
|
||||
val badge: Badge? = null,
|
||||
val controlState: ControlState? = null,
|
||||
val hasOtherBadges: Boolean = false,
|
||||
val displayingOtherBadges: Boolean = false,
|
||||
val userCheckSelection: Boolean? = false,
|
||||
val redemptionState: RedemptionState = RedemptionState.NONE
|
||||
) {
|
||||
|
||||
fun getControlChecked(): Boolean {
|
||||
return when {
|
||||
userCheckSelection != null -> userCheckSelection
|
||||
controlState == ControlState.FEATURE -> false
|
||||
!displayingOtherBadges -> false
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
enum class ControlState {
|
||||
DISPLAY,
|
||||
FEATURE
|
||||
}
|
||||
|
||||
enum class RedemptionState {
|
||||
NONE,
|
||||
IN_PROGRESS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.received
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||
import org.thoughtcrime.securesms.jobmanager.JobTracker
|
||||
import org.thoughtcrime.securesms.jobs.DonationReceiptRedemptionJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ViewReceivedGiftViewModel(
|
||||
sentFrom: RecipientId,
|
||||
private val messageId: Long,
|
||||
repository: ViewGiftRepository,
|
||||
val badgeRepository: BadgeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ViewReceivedGiftViewModel::class.java)
|
||||
}
|
||||
|
||||
private val store = RxStore(ViewReceivedGiftState())
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: Flowable<ViewReceivedGiftState> = store.stateFlowable
|
||||
|
||||
init {
|
||||
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
|
||||
store.update { it.copy(recipient = recipient) }
|
||||
}
|
||||
|
||||
disposables += repository.getGiftBadge(messageId).subscribe { giftBadge ->
|
||||
store.update {
|
||||
it.copy(giftBadge = giftBadge)
|
||||
}
|
||||
}
|
||||
|
||||
disposables += repository
|
||||
.getGiftBadge(messageId)
|
||||
.firstOrError()
|
||||
.flatMap { repository.getBadge(it) }
|
||||
.subscribe { badge ->
|
||||
val otherBadges = Recipient.self().badges.filterNot { it.id == badge.id }
|
||||
val hasOtherBadges = otherBadges.isNotEmpty()
|
||||
val displayingBadges = SignalStore.donationsValues().getDisplayBadgesOnProfile()
|
||||
val displayingOtherBadges = hasOtherBadges && displayingBadges
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
badge = badge,
|
||||
hasOtherBadges = hasOtherBadges,
|
||||
displayingOtherBadges = displayingOtherBadges,
|
||||
controlState = if (displayingBadges) ViewReceivedGiftState.ControlState.FEATURE else ViewReceivedGiftState.ControlState.DISPLAY
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun setChecked(isChecked: Boolean) {
|
||||
store.update { state ->
|
||||
state.copy(
|
||||
userCheckSelection = isChecked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun redeem(): Completable {
|
||||
val snapshot = store.state
|
||||
|
||||
return if (snapshot.controlState != null && snapshot.badge != null) {
|
||||
if (snapshot.controlState == ViewReceivedGiftState.ControlState.DISPLAY) {
|
||||
badgeRepository.setVisibilityForAllBadges(snapshot.getControlChecked()).andThen(awaitRedemptionCompletion(false))
|
||||
} else if (snapshot.getControlChecked()) {
|
||||
awaitRedemptionCompletion(true)
|
||||
} else {
|
||||
awaitRedemptionCompletion(false)
|
||||
}
|
||||
} else {
|
||||
Completable.error(Exception("Cannot enqueue a redemption without a control state or badge."))
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitRedemptionCompletion(setAsPrimary: Boolean): Completable {
|
||||
return Completable.create {
|
||||
Log.i(TAG, "Enqueuing gift redemption and awaiting result...", true)
|
||||
|
||||
var finalJobState: JobTracker.JobState? = null
|
||||
val countDownLatch = CountDownLatch(1)
|
||||
|
||||
DonationReceiptRedemptionJob.createJobChainForGift(messageId, setAsPrimary).enqueue { _, state ->
|
||||
if (state.isComplete) {
|
||||
finalJobState = state
|
||||
countDownLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (countDownLatch.await(10, TimeUnit.SECONDS)) {
|
||||
when (finalJobState) {
|
||||
JobTracker.JobState.SUCCESS -> {
|
||||
Log.d(TAG, "Gift redemption job chain succeeded.", true)
|
||||
it.onComplete()
|
||||
}
|
||||
JobTracker.JobState.FAILURE -> {
|
||||
Log.d(TAG, "Gift redemption job chain failed permanently.", true)
|
||||
it.onError(DonationError.genericBadgeRedemptionFailure(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Gift redemption job chain ignored due to in-progress jobs.", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Timeout awaiting for gift token redemption and profile refresh", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
Log.w(TAG, "Interrupted awaiting for gift token redemption and profile refresh", true)
|
||||
it.onError(DonationError.timeoutWaitingForToken(DonationErrorSource.GIFT_REDEMPTION))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val sentFrom: RecipientId,
|
||||
private val messageId: Long,
|
||||
private val repository: ViewGiftRepository,
|
||||
private val badgeRepository: BadgeRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ViewReceivedGiftViewModel(sentFrom, messageId, repository, badgeRepository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Handles all interactions for received gift badges.
|
||||
*/
|
||||
class ViewSentGiftBottomSheet : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
companion object {
|
||||
private const val ARG_GIFT_BADGE = "arg.gift.badge"
|
||||
private const val ARG_SENT_TO = "arg.sent.to"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, messageRecord: MmsMessageRecord) {
|
||||
ViewSentGiftBottomSheet().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_SENT_TO, messageRecord.recipient.id)
|
||||
putByteArray(ARG_GIFT_BADGE, messageRecord.giftBadge!!.toByteArray())
|
||||
}
|
||||
show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sentTo: RecipientId
|
||||
get() = requireArguments().getParcelable(ARG_SENT_TO)!!
|
||||
|
||||
private val giftBadge: GiftBadge
|
||||
get() = GiftBadge.parseFrom(requireArguments().getByteArray(ARG_GIFT_BADGE))
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val viewModel: ViewSentGiftViewModel by viewModels(
|
||||
factoryProducer = { ViewSentGiftViewModel.Factory(sentTo, giftBadge, ViewGiftRepository()) }
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
BadgeDisplay112.register(adapter)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: ViewSentGiftState): DSLConfiguration {
|
||||
return configure {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
stringId = R.string.ViewSentGiftBottomSheet__thanks_for_your_support,
|
||||
DSLSettingsText.CenterModifier, DSLSettingsText.Title2BoldModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(8f).toInt())
|
||||
|
||||
if (state.recipient != null) {
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
charSequence = getString(R.string.ViewSentGiftBottomSheet__youve_gifted_a_badge, state.recipient.getDisplayName(requireContext())),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(30f).toInt())
|
||||
}
|
||||
|
||||
if (state.badge != null) {
|
||||
customPref(
|
||||
BadgeDisplay112.Model(
|
||||
badge = state.badge
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
|
||||
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
data class ViewSentGiftState(
|
||||
val recipient: Recipient? = null,
|
||||
val badge: Badge? = null
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.viewgift.sent
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import org.thoughtcrime.securesms.badges.gifts.viewgift.ViewGiftRepository
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
class ViewSentGiftViewModel(
|
||||
sentFrom: RecipientId,
|
||||
giftBadge: GiftBadge,
|
||||
repository: ViewGiftRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val store = RxStore(ViewSentGiftState())
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
val state: Flowable<ViewSentGiftState> = store.stateFlowable
|
||||
|
||||
init {
|
||||
disposables += Recipient.observable(sentFrom).subscribe { recipient ->
|
||||
store.update { it.copy(recipient = recipient) }
|
||||
}
|
||||
|
||||
disposables += repository.getBadge(giftBadge).subscribe { badge ->
|
||||
store.update {
|
||||
it.copy(
|
||||
badge = badge
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
private val sentFrom: RecipientId,
|
||||
private val giftBadge: GiftBadge,
|
||||
private val repository: ViewGiftRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ViewSentGiftViewModel(sentFrom, giftBadge, repository)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ data class Badge(
|
||||
|
||||
companion object {
|
||||
const val BOOST_BADGE_ID = "BOOST"
|
||||
const val GIFT_BADGE_ID = "GIFT"
|
||||
|
||||
private val SELECTION_CHANGED = Any()
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Displays a 112dp badge.
|
||||
*/
|
||||
object BadgeDisplay112 {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_112))
|
||||
mappingAdapter.registerFactory(GiftModel::class.java, LayoutFactory(::GiftViewHolder, R.layout.badge_display_112))
|
||||
}
|
||||
|
||||
class Model(val badge: Badge, val withDisplayText: Boolean = true) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge && withDisplayText == newItem.withDisplayText
|
||||
}
|
||||
|
||||
class GiftModel(val giftBadge: GiftBadge) : MappingModel<GiftModel> {
|
||||
override fun areItemsTheSame(newItem: GiftModel): Boolean = giftBadge.redemptionToken == newItem.giftBadge.redemptionToken
|
||||
override fun areContentsTheSame(newItem: GiftModel): Boolean = giftBadge == newItem.giftBadge
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val titleView: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
titleView.text = model.badge.name
|
||||
titleView.visible = model.withDisplayText
|
||||
badgeImageView.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
|
||||
class GiftViewHolder(itemView: View) : MappingViewHolder<GiftModel>(itemView) {
|
||||
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val titleView: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
override fun bind(model: GiftModel) {
|
||||
titleView.visible = false
|
||||
badgeImageView.setGiftBadge(model.giftBadge, GlideApp.with(badgeImageView))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.badges.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* Displays a 160dp badge.
|
||||
*/
|
||||
object BadgeDisplay160 {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.badge_display_160))
|
||||
}
|
||||
|
||||
class Model(val badge: Badge) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = badge.id == newItem.badge.id
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean = badge == newItem.badge
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val badgeImageView: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||
private val titleView: TextView = itemView.findViewById(R.id.name)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
titleView.text = model.badge.name
|
||||
badgeImageView.setBadge(model.badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,48 +4,40 @@ import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
object BadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(BadgeModel.FeaturedModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(BadgeModel.SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||
mappingAdapter.registerFactory(BadgeModel.GiftedBadgeModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.gift_badge_preview_preference))
|
||||
}
|
||||
|
||||
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
|
||||
sealed class BadgeModel<T : BadgeModel<T>> : MappingModel<T> {
|
||||
abstract val badge: Badge?
|
||||
}
|
||||
abstract val recipient: Recipient
|
||||
|
||||
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return true
|
||||
data class FeaturedModel(override val badge: Badge?) : BadgeModel<FeaturedModel>() {
|
||||
override val recipient: Recipient = Recipient.self()
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override val recipient: Recipient = Recipient.self()
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Model): Any? {
|
||||
return Unit
|
||||
}
|
||||
}
|
||||
data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel<GiftedBadgeModel>()
|
||||
|
||||
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return true
|
||||
override fun areItemsTheSame(newItem: T): Boolean {
|
||||
return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: SubscriptionModel): Any? {
|
||||
return Unit
|
||||
override fun areContentsTheSame(newItem: T): Boolean {
|
||||
return badge == newItem.badge && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +48,7 @@ object BadgePreview {
|
||||
|
||||
override fun bind(model: T) {
|
||||
if (payload.isEmpty()) {
|
||||
avatar.setRecipient(Recipient.self())
|
||||
avatar.setRecipient(model.recipient)
|
||||
avatar.disableQuickContact()
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class CantProcessSubscriptionPaymentBottomSheetDialogFragment : DSLSettingsBotto
|
||||
text = DSLSettingsText.from(R.string.CantProcessSubscriptionPaymentBottomSheetDialogFragment__dont_show_this_again)
|
||||
) {
|
||||
SignalStore.donationsValues().showCantProcessDialog = false
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
sectionHeaderPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__boost_badge_expired
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__subscription_cancelled
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__monthly_donation_cancelled
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
@@ -54,11 +54,11 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
noPadTextPref(
|
||||
DSLSettingsText.from(
|
||||
if (badge.isBoost()) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired)
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired_and)
|
||||
} else if (inactive) {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_automatically, badge.name)
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_automatically, badge.name)
|
||||
} else {
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer_subscription_was_canceled)
|
||||
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_recurring_monthly_donation_was_canceled)
|
||||
},
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
@@ -72,7 +72,7 @@ class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||
if (isLikelyASustainer) {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting_technology
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_keep
|
||||
}
|
||||
} else {
|
||||
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
|
||||
|
||||
@@ -58,7 +58,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
val previewView: View = requireView().findViewById(R.id.preview)
|
||||
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.Model>(previewView)
|
||||
val previewViewHolder = BadgePreview.ViewHolder<BadgePreview.BadgeModel.FeaturedModel>(previewView)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
lifecycleDisposable += viewModel.events.subscribe { event: SelectFeaturedBadgeEvent ->
|
||||
@@ -79,7 +79,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
hasBoundPreview = true
|
||||
}
|
||||
|
||||
previewViewHolder.bind(BadgePreview.Model(state.selectedBadge))
|
||||
previewViewHolder.bind(BadgePreview.BadgeModel.FeaturedModel(state.selectedBadge))
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private fun getConfiguration(state: BecomeASustainerState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(BadgePreview.Model(badge = state.badge))
|
||||
customPref(BadgePreview.BadgeModel.FeaturedModel(badge = state.badge))
|
||||
|
||||
sectionHeaderPref(
|
||||
title = DSLSettingsText.from(
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
@@ -69,6 +70,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
contactFilterView.focusAndShowKeyboard();
|
||||
} else {
|
||||
contactFilterView.setVisibility(View.GONE);
|
||||
ViewUtil.hideKeyboard(this, contactFilterView);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.components.mention.MentionValidatorWatcher;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.StringUtil;
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.view.ViewCompat
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
@@ -26,6 +28,9 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
@StyleRes
|
||||
protected open val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners
|
||||
|
||||
@ColorInt
|
||||
protected var backgroundColor: Int = Color.TRANSPARENT
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NORMAL, themeResId)
|
||||
@@ -44,7 +49,8 @@ abstract class FixedRoundedCornerBottomSheetDialogFragment : BottomSheetDialogFr
|
||||
val dialogBackground = MaterialShapeDrawable(shapeAppearanceModel)
|
||||
|
||||
val bottomSheetStyle = ThemeUtil.getThemedResourceId(ContextThemeWrapper(requireContext(), themeResId), R.attr.bottomSheetStyle)
|
||||
dialogBackground.setTint(ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint))
|
||||
backgroundColor = ThemeUtil.getThemedColor(ContextThemeWrapper(requireContext(), bottomSheetStyle), R.attr.backgroundTint)
|
||||
dialogBackground.setTint(backgroundColor)
|
||||
|
||||
dialog.behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() {
|
||||
override fun onStateChanged(bottomSheet: View, newState: Int) {
|
||||
|
||||
@@ -62,7 +62,7 @@ public class FromTextView extends SimpleEmojiTextView {
|
||||
builder.append(suffix);
|
||||
}
|
||||
|
||||
if (recipient.isReleaseNotes()) {
|
||||
if (recipient.showVerified()) {
|
||||
Drawable official = ContextUtil.requireDrawable(getContext(), R.drawable.ic_official_20);
|
||||
official.setBounds(0, 0, ViewUtil.dpToPx(20), ViewUtil.dpToPx(20));
|
||||
|
||||
|
||||
@@ -179,9 +179,10 @@ public class InputPanel extends LinearLayout
|
||||
long id,
|
||||
@NonNull Recipient author,
|
||||
@NonNull CharSequence body,
|
||||
@NonNull SlideDeck attachments)
|
||||
@NonNull SlideDeck attachments,
|
||||
@NonNull QuoteModel.Type quoteType)
|
||||
{
|
||||
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null);
|
||||
this.quoteView.setQuote(glideRequests, id, author, body, false, attachments, null, null, quoteType);
|
||||
|
||||
int originalHeight = this.quoteView.getVisibility() == VISIBLE ? this.quoteView.getMeasuredHeight()
|
||||
: 0;
|
||||
@@ -256,7 +257,7 @@ public class InputPanel extends LinearLayout
|
||||
|
||||
public Optional<QuoteModel> getQuote() {
|
||||
if (quoteView.getQuoteId() > 0 && quoteView.getVisibility() == View.VISIBLE) {
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions()));
|
||||
return Optional.of(new QuoteModel(quoteView.getQuoteId(), quoteView.getAuthor().getId(), quoteView.getBody().toString(), false, quoteView.getAttachments(), quoteView.getMentions(), quoteView.getQuoteType()));
|
||||
} else {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ abstract class KeyboardEntryDialogFragment(@LayoutRes contentLayoutId: Int) :
|
||||
dialog.window?.setDimAmount(0f)
|
||||
}
|
||||
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Copyright (C) 2011 Whisper Systems
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.thoughtcrime.securesms.components;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.RelativeLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.annimon.stream.Stream;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.RecipientsEditor;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.StringTokenizer;
|
||||
|
||||
/**
|
||||
* Panel component combining both an editable field with a button for
|
||||
* a list-based contact selector.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*/
|
||||
public class PushRecipientsPanel extends RelativeLayout implements RecipientForeverObserver {
|
||||
private final String TAG = Log.tag(PushRecipientsPanel.class);
|
||||
private RecipientsPanelChangedListener panelChangeListener;
|
||||
|
||||
private RecipientsEditor recipientsText;
|
||||
private View panel;
|
||||
|
||||
private static final int RECIPIENTS_MAX_LENGTH = 312;
|
||||
|
||||
public PushRecipientsPanel(Context context) {
|
||||
super(context);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
initialize();
|
||||
}
|
||||
|
||||
public PushRecipientsPanel(Context context, AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
initialize();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
Stream.of(getRecipients()).map(Recipient::live).forEach(r -> r.removeForeverObserver(this));
|
||||
}
|
||||
|
||||
public List<Recipient> getRecipients() {
|
||||
String rawText = recipientsText.getText().toString();
|
||||
return getRecipientsFromString(getContext(), rawText);
|
||||
}
|
||||
|
||||
public void disable() {
|
||||
recipientsText.setText("");
|
||||
panel.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
public void setPanelChangeListener(RecipientsPanelChangedListener panelChangeListener) {
|
||||
this.panelChangeListener = panelChangeListener;
|
||||
}
|
||||
|
||||
private void initialize() {
|
||||
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
|
||||
inflater.inflate(R.layout.push_recipients_panel, this, true);
|
||||
|
||||
View imageButton = findViewById(R.id.contacts_button);
|
||||
((MarginLayoutParams) imageButton.getLayoutParams()).topMargin = 0;
|
||||
|
||||
panel = findViewById(R.id.recipients_panel);
|
||||
initRecipientsEditor();
|
||||
}
|
||||
|
||||
private void initRecipientsEditor() {
|
||||
|
||||
this.recipientsText = (RecipientsEditor)findViewById(R.id.recipients_text);
|
||||
|
||||
List<Recipient> recipients = getRecipients();
|
||||
|
||||
Stream.of(recipients).map(Recipient::live).forEach(r -> r.observeForever(this));
|
||||
|
||||
recipientsText.setAdapter(new RecipientsAdapter(this.getContext()));
|
||||
recipientsText.populate(recipients);
|
||||
|
||||
recipientsText.setOnFocusChangeListener(new FocusChangedListener());
|
||||
recipientsText.setOnItemClickListener(new AdapterView.OnItemClickListener() {
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) {
|
||||
if (panelChangeListener != null) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
recipientsText.setText("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private @NonNull List<Recipient> getRecipientsFromString(Context context, @NonNull String rawText) {
|
||||
StringTokenizer tokenizer = new StringTokenizer(rawText, ",");
|
||||
List<Recipient> recipients = new LinkedList<>();
|
||||
|
||||
while (tokenizer.hasMoreTokens()) {
|
||||
String token = tokenizer.nextToken().trim();
|
||||
|
||||
if (!TextUtils.isEmpty(token)) {
|
||||
if (hasBracketedNumber(token)) recipients.add(Recipient.external(context, parseBracketedNumber(token)));
|
||||
else recipients.add(Recipient.external(context, token));
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
|
||||
private boolean hasBracketedNumber(String recipient) {
|
||||
int openBracketIndex = recipient.indexOf('<');
|
||||
|
||||
return (openBracketIndex != -1) &&
|
||||
(recipient.indexOf('>', openBracketIndex) != -1);
|
||||
}
|
||||
|
||||
private String parseBracketedNumber(String recipient) {
|
||||
int begin = recipient.indexOf('<');
|
||||
int end = recipient.indexOf('>', begin);
|
||||
String value = recipient.substring(begin + 1, end);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRecipientChanged(@NonNull Recipient recipient) {
|
||||
recipientsText.populate(getRecipients());
|
||||
}
|
||||
|
||||
private class FocusChangedListener implements View.OnFocusChangeListener {
|
||||
public void onFocusChange(View v, boolean hasFocus) {
|
||||
if (!hasFocus && (panelChangeListener != null)) {
|
||||
panelChangeListener.onRecipientsPanelUpdate(getRecipients());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface RecipientsPanelChangedListener {
|
||||
public void onRecipientsPanelUpdate(List<Recipient> recipients);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
@@ -24,12 +25,13 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
|
||||
import org.thoughtcrime.securesms.database.model.Mention;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.StoryTextPost;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
@@ -39,7 +41,9 @@ import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
@@ -51,8 +55,9 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
PREVIEW(0),
|
||||
OUTGOING(1),
|
||||
INCOMING(2),
|
||||
STORY_REPLY(3),
|
||||
STORY_REPLY_PREVIEW(4);
|
||||
STORY_REPLY_OUTGOING(3),
|
||||
STORY_REPLY_INCOMING(4),
|
||||
STORY_REPLY_PREVIEW(5);
|
||||
|
||||
private final int code;
|
||||
|
||||
@@ -71,27 +76,30 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private ViewGroup mainView;
|
||||
private ViewGroup footerView;
|
||||
private TextView authorView;
|
||||
private TextView bodyView;
|
||||
private View quoteBarView;
|
||||
private ImageView thumbnailView;
|
||||
private View attachmentVideoOverlayView;
|
||||
private ViewGroup attachmentContainerView;
|
||||
private TextView attachmentNameView;
|
||||
private ImageView dismissView;
|
||||
private ViewGroup mainView;
|
||||
private ViewGroup footerView;
|
||||
private TextView authorView;
|
||||
private TextView bodyView;
|
||||
private View quoteBarView;
|
||||
private ImageView thumbnailView;
|
||||
private View attachmentVideoOverlayView;
|
||||
private ViewGroup attachmentContainerView;
|
||||
private TextView attachmentNameView;
|
||||
private ImageView dismissView;
|
||||
private EmojiImageView missingStoryReaction;
|
||||
private EmojiImageView storyReactionEmoji;
|
||||
|
||||
private long id;
|
||||
private LiveRecipient author;
|
||||
private CharSequence body;
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private SlideDeck attachments;
|
||||
private MessageType messageType;
|
||||
private int largeCornerRadius;
|
||||
private int smallCornerRadius;
|
||||
private CornerMask cornerMask;
|
||||
private long id;
|
||||
private LiveRecipient author;
|
||||
private CharSequence body;
|
||||
private TextView mediaDescriptionText;
|
||||
private TextView missingLinkText;
|
||||
private SlideDeck attachments;
|
||||
private MessageType messageType;
|
||||
private int largeCornerRadius;
|
||||
private int smallCornerRadius;
|
||||
private CornerMask cornerMask;
|
||||
private QuoteModel.Type quoteType;
|
||||
|
||||
private int thumbHeight;
|
||||
private int thumbWidth;
|
||||
@@ -132,6 +140,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
this.dismissView = findViewById(R.id.quote_dismiss);
|
||||
this.mediaDescriptionText = findViewById(R.id.media_type);
|
||||
this.missingLinkText = findViewById(R.id.quote_missing_text);
|
||||
this.missingStoryReaction = findViewById(R.id.quote_missing_story_reaction_emoji);
|
||||
this.storyReactionEmoji = findViewById(R.id.quote_story_reaction_emoji);
|
||||
this.largeCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_large);
|
||||
this.smallCornerRadius = getResources().getDimensionPixelSize(R.dimen.quote_corner_radius_bottom);
|
||||
|
||||
@@ -185,8 +195,6 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
thumbHeight = getResources().getDimensionPixelOffset(R.dimen.quote_story_thumb_height);
|
||||
}
|
||||
|
||||
mainView.setMinimumHeight(thumbHeight);
|
||||
|
||||
ViewGroup.LayoutParams params = thumbnailView.getLayoutParams();
|
||||
params.height = thumbHeight;
|
||||
params.width = thumbWidth;
|
||||
@@ -200,7 +208,9 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
@Nullable CharSequence body,
|
||||
boolean originalMissing,
|
||||
@NonNull SlideDeck attachments,
|
||||
@Nullable ChatColors chatColors)
|
||||
@Nullable ChatColors chatColors,
|
||||
@Nullable String storyReaction,
|
||||
@NonNull QuoteModel.Type quoteType)
|
||||
{
|
||||
if (this.author != null) this.author.removeForeverObserver(this);
|
||||
|
||||
@@ -208,11 +218,12 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
this.author = author.live();
|
||||
this.body = body;
|
||||
this.attachments = attachments;
|
||||
this.quoteType = quoteType;
|
||||
|
||||
this.author.observeForever(this);
|
||||
setQuoteAuthor(author);
|
||||
setQuoteText(body, attachments);
|
||||
setQuoteAttachment(glideRequests, body, attachments);
|
||||
setQuoteText(resolveBody(body, quoteType), attachments, originalMissing, storyReaction);
|
||||
setQuoteAttachment(glideRequests, body, attachments, originalMissing);
|
||||
setQuoteMissingFooter(originalMissing);
|
||||
|
||||
if (Build.VERSION.SDK_INT < 21 && messageType == MessageType.INCOMING && chatColors != null) {
|
||||
@@ -222,6 +233,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable CharSequence resolveBody(@Nullable CharSequence body, @NonNull QuoteModel.Type quoteType) {
|
||||
return quoteType == QuoteModel.Type.GIFT_BADGE ? getContext().getString(R.string.QuoteView__gift) : body;
|
||||
}
|
||||
|
||||
public void setTopCornerSizes(boolean topLeftLarge, boolean topRightLarge) {
|
||||
cornerMask.setTopLeftRadius(topLeftLarge ? largeCornerRadius : smallCornerRadius);
|
||||
cornerMask.setTopRightRadius(topRightLarge ? largeCornerRadius : smallCornerRadius);
|
||||
@@ -251,7 +266,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
|
||||
private void setQuoteAuthor(@NonNull Recipient author) {
|
||||
boolean outgoing = messageType != MessageType.INCOMING;
|
||||
boolean outgoing = messageType != MessageType.INCOMING && messageType != MessageType.STORY_REPLY_INCOMING;
|
||||
boolean preview = messageType == MessageType.PREVIEW || messageType == MessageType.STORY_REPLY_PREVIEW;
|
||||
|
||||
if (isStoryReply()) {
|
||||
@@ -262,21 +277,61 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
: author.getDisplayName(getContext()));
|
||||
}
|
||||
|
||||
quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing ? R.color.core_white : android.R.color.transparent));
|
||||
mainView.setBackgroundColor(ContextCompat.getColor(getContext(), preview ? R.color.quote_preview_background : R.color.quote_view_background));
|
||||
quoteBarView.setBackgroundColor(ContextCompat.getColor(getContext(), outgoing || isStoryReply() ? R.color.core_white : android.R.color.transparent));
|
||||
|
||||
int mainViewColor;
|
||||
if (preview) {
|
||||
mainViewColor = R.color.quote_preview_background;
|
||||
} else if (!outgoing && isStoryReply()) {
|
||||
mainViewColor = R.color.quote_incoming_story_background;
|
||||
} else {
|
||||
mainViewColor = R.color.quote_view_background;
|
||||
}
|
||||
|
||||
mainView.setBackgroundColor(ContextCompat.getColor(getContext(), mainViewColor));
|
||||
}
|
||||
|
||||
private boolean isStoryReply() {
|
||||
return messageType == MessageType.STORY_REPLY || messageType == MessageType.STORY_REPLY_PREVIEW;
|
||||
return messageType == MessageType.STORY_REPLY_OUTGOING ||
|
||||
messageType == MessageType.STORY_REPLY_INCOMING ||
|
||||
messageType == MessageType.STORY_REPLY_PREVIEW;
|
||||
}
|
||||
|
||||
private void setQuoteText(@Nullable CharSequence body, @NonNull SlideDeck attachments) {
|
||||
private void setQuoteText(@Nullable CharSequence body,
|
||||
@NonNull SlideDeck attachments,
|
||||
boolean originalMissing,
|
||||
@Nullable String storyReaction)
|
||||
{
|
||||
if (originalMissing && isStoryReply()) {
|
||||
bodyView.setVisibility(GONE);
|
||||
storyReactionEmoji.setVisibility(View.GONE);
|
||||
mediaDescriptionText.setVisibility(VISIBLE);
|
||||
|
||||
mediaDescriptionText.setText(R.string.QuoteView_no_longer_available);
|
||||
if (storyReaction != null) {
|
||||
missingStoryReaction.setVisibility(View.VISIBLE);
|
||||
missingStoryReaction.setImageEmoji(storyReaction);
|
||||
} else {
|
||||
missingStoryReaction.setVisibility(View.GONE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (storyReaction != null) {
|
||||
storyReactionEmoji.setImageEmoji(storyReaction);
|
||||
storyReactionEmoji.setVisibility(View.VISIBLE);
|
||||
missingStoryReaction.setVisibility(View.INVISIBLE);
|
||||
} else {
|
||||
storyReactionEmoji.setVisibility(View.GONE);
|
||||
missingStoryReaction.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
boolean isTextStory = !attachments.containsMediaSlide() && isStoryReply();
|
||||
|
||||
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
|
||||
if (isTextStory && body != null) {
|
||||
try {
|
||||
bodyView.setText(StoryTextPostModel.parseFrom(body.toString(), id, author.getId()).getText());
|
||||
bodyView.setText(getStoryTextPost(body).getText());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not parse body of text post.", e);
|
||||
bodyView.setText("");
|
||||
@@ -324,9 +379,11 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
}
|
||||
|
||||
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck) {
|
||||
private void setQuoteAttachment(@NonNull GlideRequests glideRequests, @NonNull CharSequence body, @NonNull SlideDeck slideDeck, boolean originalMissing) {
|
||||
mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
|
||||
|
||||
if (!attachments.containsMediaSlide() && isStoryReply()) {
|
||||
StoryTextPostModel model = StoryTextPostModel.parseFrom(body.toString(), id, author.getId());
|
||||
StoryTextPostModel model = getStoryTextPost(body);
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
@@ -338,6 +395,18 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
return;
|
||||
}
|
||||
|
||||
if (quoteType == QuoteModel.Type.GIFT_BADGE) {
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
glideRequests.load(R.drawable.ic_gift_thumbnail)
|
||||
.centerCrop()
|
||||
.override(thumbWidth, thumbHeight)
|
||||
.diskCacheStrategy(DiskCacheStrategy.RESOURCE)
|
||||
.into(thumbnailView);
|
||||
return;
|
||||
}
|
||||
|
||||
Slide imageVideoSlide = slideDeck.getSlides().stream().filter(s -> s.hasImage() || s.hasVideo() || s.hasSticker()).findFirst().orElse(null);
|
||||
Slide documentSlide = slideDeck.getSlides().stream().filter(Slide::hasDocument).findFirst().orElse(null);
|
||||
Slide viewOnceSlide = slideDeck.getSlides().stream().filter(Slide::hasViewOnce).findFirst().orElse(null);
|
||||
@@ -375,10 +444,22 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
}
|
||||
|
||||
private void setQuoteMissingFooter(boolean missing) {
|
||||
footerView.setVisibility(missing ? VISIBLE : GONE);
|
||||
footerView.setVisibility(missing && !isStoryReply() ? VISIBLE : GONE);
|
||||
footerView.setBackgroundColor(ContextCompat.getColor(getContext(), R.color.quote_view_background));
|
||||
}
|
||||
|
||||
private @Nullable StoryTextPostModel getStoryTextPost(@Nullable CharSequence body) {
|
||||
if (Util.isEmpty(body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return StoryTextPostModel.parseFrom(body.toString(), id, author.getId());
|
||||
} catch (IOException ioException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void setTextSize(int unit, float size) {
|
||||
bodyView.setTextSize(unit, size);
|
||||
}
|
||||
@@ -399,6 +480,10 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
return attachments.asAttachments();
|
||||
}
|
||||
|
||||
public @NonNull QuoteModel.Type getQuoteType() {
|
||||
return quoteType;
|
||||
}
|
||||
|
||||
public @NonNull List<Mention> getMentions() {
|
||||
return MentionAnnotation.getMentionsFromAnnotations(body);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ public class SearchToolbar extends LinearLayout {
|
||||
EditText searchText = searchView.findViewById(R.id.search_src_text);
|
||||
|
||||
searchView.setSubmitButtonEnabled(false);
|
||||
searchView.setMaxWidth(Integer.MAX_VALUE);
|
||||
|
||||
if (searchText != null) searchText.setHint(R.string.SearchToolbar_search);
|
||||
else searchView.setQueryHint(getResources().getString(R.string.SearchToolbar_search));
|
||||
@@ -65,7 +66,9 @@ public class SearchToolbar extends LinearLayout {
|
||||
searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
|
||||
@Override
|
||||
public boolean onQueryTextSubmit(String query) {
|
||||
if (listener != null) listener.onSearchTextChange(query);
|
||||
if (listener != null) {
|
||||
listener.onSearchTextChange(query);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||