mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 15:37:29 +00:00
Compare commits
723 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09afb1be41 | ||
|
|
ad2ebfb389 | ||
|
|
85d7a5c6cc | ||
|
|
4fbbc9d395 | ||
|
|
e3954ab5e8 | ||
|
|
c1b19390a2 | ||
|
|
f7e4e9c855 | ||
|
|
5c6f709faa | ||
|
|
47f1d3f594 | ||
|
|
2b10f93718 | ||
|
|
ccee7577f7 | ||
|
|
4e871e2dd8 | ||
|
|
455da6649b | ||
|
|
dc4acd83e8 | ||
|
|
0e3a9a3130 | ||
|
|
ed2edc1ebb | ||
|
|
6f4de36c6f | ||
|
|
e10696b44e | ||
|
|
6ed1c21a66 | ||
|
|
263f7ebac5 | ||
|
|
c3063b721d | ||
|
|
1dc29fda12 | ||
|
|
28193c2f61 | ||
|
|
9d71c4df81 | ||
|
|
c563ef27da | ||
|
|
8eb3a1906e | ||
|
|
0309f9ea89 | ||
|
|
d678341399 | ||
|
|
99f8ba5e0c | ||
|
|
c69b91c4db | ||
|
|
8f56c1baa5 | ||
|
|
a0d4026e40 | ||
|
|
975b242a08 | ||
|
|
f1fafa6516 | ||
|
|
fca412b47d | ||
|
|
18c32a7a80 | ||
|
|
f96c31b38f | ||
|
|
65a4ef2f70 | ||
|
|
2b685ea89f | ||
|
|
b55954380d | ||
|
|
739a8e9451 | ||
|
|
433b5ebc13 | ||
|
|
018bb49a03 | ||
|
|
fc145d7367 | ||
|
|
c5f05f322f | ||
|
|
b419eb4cd5 | ||
|
|
9bdf65c4e4 | ||
|
|
dbbae7f13f | ||
|
|
513228b366 | ||
|
|
a2415261bd | ||
|
|
8f06381239 | ||
|
|
f6f1fdb87d | ||
|
|
b8e16353ab | ||
|
|
16cbc971a5 | ||
|
|
d1df069669 | ||
|
|
844480786e | ||
|
|
77aa0424fd | ||
|
|
4d94d9d968 | ||
|
|
89fca76327 | ||
|
|
14b9518a48 | ||
|
|
512ba2b0a8 | ||
|
|
9851bc300e | ||
|
|
a81a4cdb53 | ||
|
|
b1d1aee373 | ||
|
|
2cfa31a9b0 | ||
|
|
67b6cd164e | ||
|
|
f241a51fe1 | ||
|
|
74c542099a | ||
|
|
5d76f13c51 | ||
|
|
c6d38600ec | ||
|
|
fc3db538bc | ||
|
|
acbccc32a6 | ||
|
|
97a502c8c7 | ||
|
|
bdba048bc4 | ||
|
|
f7adf2ee5a | ||
|
|
dcc9b8ca66 | ||
|
|
7ad6d95b27 | ||
|
|
2856697109 | ||
|
|
af89d85696 | ||
|
|
c218e22566 | ||
|
|
b38ac44d0f | ||
|
|
2709f0ee0d | ||
|
|
c1f84adb2f | ||
|
|
8c6b7ecc4c | ||
|
|
5e25e8d0a2 | ||
|
|
c674d5b674 | ||
|
|
377841db26 | ||
|
|
5da7052da3 | ||
|
|
ffeb60fcdd | ||
|
|
e610ee419f | ||
|
|
d61a35b118 | ||
|
|
ac189865b9 | ||
|
|
8056aafc9d | ||
|
|
8ab16164eb | ||
|
|
473c8b199e | ||
|
|
3692d87531 | ||
|
|
99a516f8e5 | ||
|
|
0ff4175538 | ||
|
|
4e8208c468 | ||
|
|
84f0548966 | ||
|
|
77beeda62a | ||
|
|
8c915572fb | ||
|
|
53883ee3d3 | ||
|
|
40ca16bd06 | ||
|
|
60dcfb2fe6 | ||
|
|
123fb95916 | ||
|
|
1a657a7a19 | ||
|
|
f119496da4 | ||
|
|
6999d1fbf1 | ||
|
|
c1ff2aeeff | ||
|
|
4220395649 | ||
|
|
806409b329 | ||
|
|
3e3296da5b | ||
|
|
4bbe01cbc3 | ||
|
|
c357c35303 | ||
|
|
2ea5c7e3bc | ||
|
|
5b8a729afc | ||
|
|
4077dc829a | ||
|
|
2cfa685ae2 | ||
|
|
c686d33a46 | ||
|
|
e00ed81e7c | ||
|
|
06c9dbe6ec | ||
|
|
05377d26de | ||
|
|
b781de2c17 | ||
|
|
9f2c7a65ac | ||
|
|
bae070e60e | ||
|
|
34f6d52758 | ||
|
|
72aac0732c | ||
|
|
5da6321c67 | ||
|
|
4b9e4d739f | ||
|
|
5d4d6db197 | ||
|
|
4e3bfadfbe | ||
|
|
abb0a25b81 | ||
|
|
e369f56eab | ||
|
|
a066271766 | ||
|
|
c6eb241261 | ||
|
|
906441c90c | ||
|
|
6f46e9000b | ||
|
|
9ef58516e2 | ||
|
|
10950756d3 | ||
|
|
7c4c146189 | ||
|
|
2f0f4f94a2 | ||
|
|
3600a4818c | ||
|
|
d003dc435a | ||
|
|
8e1ec5ab5b | ||
|
|
14781c3aed | ||
|
|
490e29f758 | ||
|
|
a0c48bed6e | ||
|
|
1011e4b7f5 | ||
|
|
9602084125 | ||
|
|
36fddbb79a | ||
|
|
85d5ea0382 | ||
|
|
b4d3690d3a | ||
|
|
529211c3a5 | ||
|
|
2b4c01c106 | ||
|
|
168832c138 | ||
|
|
07915db7bc | ||
|
|
3cc1c39f81 | ||
|
|
59de56439a | ||
|
|
7759ad283d | ||
|
|
b8174c5e00 | ||
|
|
9de6c44b16 | ||
|
|
738676ea5f | ||
|
|
09361b2d40 | ||
|
|
6055515be9 | ||
|
|
37ff750261 | ||
|
|
bc97058ced | ||
|
|
1e88fb428d | ||
|
|
d2b72fc8b7 | ||
|
|
469cab284e | ||
|
|
6c0b63d72c | ||
|
|
1007b4d635 | ||
|
|
fb8b230442 | ||
|
|
371267a1d3 | ||
|
|
084e806c25 | ||
|
|
32fbbf2b55 | ||
|
|
7f4e964ec8 | ||
|
|
3fefc17582 | ||
|
|
62d5777c39 | ||
|
|
367ff7c75c | ||
|
|
1174bc8e07 | ||
|
|
27c3607099 | ||
|
|
7088b1a302 | ||
|
|
3826ac553d | ||
|
|
0819c8d2b9 | ||
|
|
341b8effcf | ||
|
|
ea9bf0ccd5 | ||
|
|
3d14c05114 | ||
|
|
3a78031a71 | ||
|
|
daa3721145 | ||
|
|
c829fba332 | ||
|
|
7fafa4d5e6 | ||
|
|
1f581c074d | ||
|
|
556d267084 | ||
|
|
3a7be812eb | ||
|
|
807e6d4e71 | ||
|
|
c1c138ce49 | ||
|
|
a15e97cc06 | ||
|
|
48e0a00a8a | ||
|
|
b46e129c23 | ||
|
|
064f7abd92 | ||
|
|
428ab65d8a | ||
|
|
94f072c5aa | ||
|
|
91f0b75a80 | ||
|
|
cb65347bb3 | ||
|
|
41aad39c62 | ||
|
|
390f6c2462 | ||
|
|
163c7de327 | ||
|
|
08b7dcb1ee | ||
|
|
dfdf68b7b5 | ||
|
|
9941ffe79c | ||
|
|
418083d0c7 | ||
|
|
25ac462921 | ||
|
|
f401ee00a1 | ||
|
|
94bd3101c9 | ||
|
|
995a4ad6ec | ||
|
|
36206dfa9a | ||
|
|
a176188c7d | ||
|
|
44f551acc5 | ||
|
|
39c1939470 | ||
|
|
ba2d84005d | ||
|
|
f54f9b7011 | ||
|
|
6600857259 | ||
|
|
e465f35e50 | ||
|
|
20eda03a5a | ||
|
|
2315a1c632 | ||
|
|
ca36eaacce | ||
|
|
2f2711c9a3 | ||
|
|
94f135ac38 | ||
|
|
3687021051 | ||
|
|
a535b4f97c | ||
|
|
690e1e60ba | ||
|
|
262f762d7f | ||
|
|
59fe196fe0 | ||
|
|
7fccbd44c0 | ||
|
|
0d715d2c18 | ||
|
|
f0e94ebbad | ||
|
|
b324db53d3 | ||
|
|
a456c3fa32 | ||
|
|
57151145d3 | ||
|
|
ff7dcd26c8 | ||
|
|
87c024e968 | ||
|
|
b8665e41e8 | ||
|
|
1d5a83668b | ||
|
|
ea6f6bf47d | ||
|
|
da4c9926cf | ||
|
|
3b3dcdcb14 | ||
|
|
ba3dd79d4e | ||
|
|
1006af7d8a | ||
|
|
0daed8f7d7 | ||
|
|
741eb55562 | ||
|
|
2b0bf032d7 | ||
|
|
00e70212c5 | ||
|
|
14a9e22b5e | ||
|
|
7ce1f9463e | ||
|
|
9bb834e9f5 | ||
|
|
957f8754e1 | ||
|
|
6673df2514 | ||
|
|
cd619833d1 | ||
|
|
ba7bfd7171 | ||
|
|
033004719a | ||
|
|
a0172ddb2f | ||
|
|
b6db7e7af6 | ||
|
|
8a238a66e7 | ||
|
|
de29fc047e | ||
|
|
7cdaf988f2 | ||
|
|
43caec69e3 | ||
|
|
f533219bad | ||
|
|
2bbce6ad47 | ||
|
|
a04c2c30b9 | ||
|
|
68c5f8e9ae | ||
|
|
246fbc4ee9 | ||
|
|
9480cd1b7b | ||
|
|
220931d3df | ||
|
|
8c76cead58 | ||
|
|
da3623d7e6 | ||
|
|
f72c44c7c3 | ||
|
|
d2523c2661 | ||
|
|
7139f91997 | ||
|
|
371d9e8f01 | ||
|
|
a8e03e9bf2 | ||
|
|
e1c6dfb73b | ||
|
|
96d60e11b0 | ||
|
|
5662473c18 | ||
|
|
14dd71bf78 | ||
|
|
55d437e54b | ||
|
|
3d8f62ce9d | ||
|
|
19d029a643 | ||
|
|
e85ba03756 | ||
|
|
7315c991d5 | ||
|
|
83d1ab2eb5 | ||
|
|
1e491d0b51 | ||
|
|
50a7c2ba5c | ||
|
|
4cc6bb4fbe | ||
|
|
7477f3c319 | ||
|
|
1046265d23 | ||
|
|
7cc2029cd3 | ||
|
|
71ca39fd4a | ||
|
|
bfd2686610 | ||
|
|
c131fb500d | ||
|
|
cdb7f07368 | ||
|
|
de329166d2 | ||
|
|
83ae613e9a | ||
|
|
b342ce6874 | ||
|
|
bef83e4c0c | ||
|
|
db0bca00ec | ||
|
|
e3c38e635a | ||
|
|
10d4063ecf | ||
|
|
c6e3c9dd35 | ||
|
|
02d9cbe01b | ||
|
|
68237df321 | ||
|
|
c82bf826e0 | ||
|
|
8fb404a492 | ||
|
|
437d6c7a52 | ||
|
|
30b635cca2 | ||
|
|
5fb0956c16 | ||
|
|
a9f654a520 | ||
|
|
4b10ec8f02 | ||
|
|
02db5f74e9 | ||
|
|
842626e96c | ||
|
|
c239ba1e35 | ||
|
|
9aa7543f2f | ||
|
|
5c77c33dff | ||
|
|
3dd31432c8 | ||
|
|
3de75f48cf | ||
|
|
be98ff3508 | ||
|
|
04b0c01015 | ||
|
|
50ded5c92a | ||
|
|
2041756513 | ||
|
|
742d1bece0 | ||
|
|
4ee8218194 | ||
|
|
22e97457a3 | ||
|
|
9d469db7ae | ||
|
|
72347af967 | ||
|
|
e3dff46136 | ||
|
|
891c99a148 | ||
|
|
8a452ddf11 | ||
|
|
aef0ed828c | ||
|
|
9ad55e2360 | ||
|
|
f687840891 | ||
|
|
bb323dc575 | ||
|
|
c0e11fbd23 | ||
|
|
0d94794ece | ||
|
|
14e8f5cf98 | ||
|
|
b78f06f064 | ||
|
|
0b978dd9d7 | ||
|
|
da9dcc794f | ||
|
|
f3fabcbe6a | ||
|
|
95801dbdc7 | ||
|
|
0a33574f1d | ||
|
|
35f1baf965 | ||
|
|
cc5aab6be3 | ||
|
|
486e172aee | ||
|
|
ec46d6039d | ||
|
|
1fe4c45c44 | ||
|
|
9946da2cec | ||
|
|
f9a4b7cf12 | ||
|
|
1fc119e027 | ||
|
|
293bc2da47 | ||
|
|
44d4075636 | ||
|
|
23ba5c874a | ||
|
|
26709177d2 | ||
|
|
3895578d51 | ||
|
|
a9a64a3f60 | ||
|
|
2edb9eeb52 | ||
|
|
4b94509a7a | ||
|
|
ad1801108d | ||
|
|
ee00e931eb | ||
|
|
4f3910e3ae | ||
|
|
79b3b9190a | ||
|
|
afedbf40e3 | ||
|
|
437c3ffd66 | ||
|
|
083219888c | ||
|
|
c1f3e27101 | ||
|
|
52965da8a5 | ||
|
|
afe36b982f | ||
|
|
f63ce79f16 | ||
|
|
1af576c157 | ||
|
|
86a345a4f3 | ||
|
|
13bd003564 | ||
|
|
b3672273e8 | ||
|
|
e2a842b440 | ||
|
|
1999db97f2 | ||
|
|
6e5f28339d | ||
|
|
ce55f6d1c2 | ||
|
|
b8ec43f466 | ||
|
|
5b7875b763 | ||
|
|
dfcc14963d | ||
|
|
e1c3583702 | ||
|
|
9cea4931d4 | ||
|
|
6c56ef470f | ||
|
|
04822bacdc | ||
|
|
3b1ecc7015 | ||
|
|
36bd7dae60 | ||
|
|
1b784d6522 | ||
|
|
063f4d2994 | ||
|
|
4325d96a5a | ||
|
|
88c36e1ff6 | ||
|
|
c86b34bb46 | ||
|
|
6708089777 | ||
|
|
33d108cde3 | ||
|
|
612ce5d0a8 | ||
|
|
0d8ff0ead0 | ||
|
|
d413f0041b | ||
|
|
678d1c9549 | ||
|
|
7dc149ddbc | ||
|
|
09b9349f6c | ||
|
|
aeb5a9cf57 | ||
|
|
aaf8bf3280 | ||
|
|
b6d7271858 | ||
|
|
9498a34293 | ||
|
|
0cae15b7fd | ||
|
|
11e4fd7f34 | ||
|
|
31f31534ce | ||
|
|
0e4bec3977 | ||
|
|
7fef1b060f | ||
|
|
0312dfcfcd | ||
|
|
b05f4430f6 | ||
|
|
8703707d62 | ||
|
|
04eeb434c9 | ||
|
|
a8a773db43 | ||
|
|
daf78b31b5 | ||
|
|
20ce3e68f8 | ||
|
|
92d065050f | ||
|
|
1b53f09687 | ||
|
|
f4d0bf900c | ||
|
|
c652d83f81 | ||
|
|
7167ad331f | ||
|
|
9bb089d198 | ||
|
|
866853ff99 | ||
|
|
931b9f8831 | ||
|
|
e8c10cd550 | ||
|
|
1049f8bd2f | ||
|
|
9929e6549e | ||
|
|
ff28ff0e6b | ||
|
|
2a82db2b02 | ||
|
|
457c3c0526 | ||
|
|
4f803c695b | ||
|
|
bdbdcccaff | ||
|
|
8d7393e4b5 | ||
|
|
533dcfb828 | ||
|
|
e67ac95890 | ||
|
|
1b63ed0b20 | ||
|
|
07d9e29e7c | ||
|
|
c47a724654 | ||
|
|
8ca94eb3d5 | ||
|
|
11b1c9655c | ||
|
|
cf3dd70600 | ||
|
|
0bf5f15cf9 | ||
|
|
ea3fb774f8 | ||
|
|
25c0dc801f | ||
|
|
c29922a575 | ||
|
|
e676f324f1 | ||
|
|
c6bfdeb4b0 | ||
|
|
80a6e0f781 | ||
|
|
bc7b0b40b0 | ||
|
|
1cea615675 | ||
|
|
9dd96148d1 | ||
|
|
9e094dfc2b | ||
|
|
a39b09c314 | ||
|
|
6c4c299b28 | ||
|
|
a98cc5706f | ||
|
|
7451ee1403 | ||
|
|
b9f4dc3fe9 | ||
|
|
2566d6f61f | ||
|
|
8eebdaf451 | ||
|
|
b1dacf4acd | ||
|
|
9326c1726a | ||
|
|
654b602cef | ||
|
|
a642876bda | ||
|
|
2b8041d779 | ||
|
|
8141b53c15 | ||
|
|
115d1fcf63 | ||
|
|
ffa249885e | ||
|
|
9a21f5abca | ||
|
|
552592db39 | ||
|
|
75af1b69e8 | ||
|
|
c96fec9537 | ||
|
|
a457d1f569 | ||
|
|
87f206fdc4 | ||
|
|
e845860c7c | ||
|
|
e351c74ddb | ||
|
|
aeeaef567f | ||
|
|
78a9206898 | ||
|
|
aab8bd1261 | ||
|
|
db16155b0d | ||
|
|
1b254ca185 | ||
|
|
9a6ed9bcb3 | ||
|
|
c8f0bd7b82 | ||
|
|
f6b7b9e913 | ||
|
|
840a56cbb4 | ||
|
|
aa268fc3ba | ||
|
|
889d1183b2 | ||
|
|
a8706f65d5 | ||
|
|
26bebb9811 | ||
|
|
9331e9ce89 | ||
|
|
6417f5cce0 | ||
|
|
a340ebf74a | ||
|
|
4882a4d11c | ||
|
|
b5300c877c | ||
|
|
c2b94274b0 | ||
|
|
46ec45b985 | ||
|
|
beee3b7dc3 | ||
|
|
e2a7ed86e4 | ||
|
|
95b0639ab4 | ||
|
|
d7f9582bc4 | ||
|
|
176a705079 | ||
|
|
8e9f311fca | ||
|
|
977af2c2f3 | ||
|
|
7e45fc4a3e | ||
|
|
58489bab61 | ||
|
|
0685cf4e51 | ||
|
|
9b9453734c | ||
|
|
ca0e52e141 | ||
|
|
24b7593178 | ||
|
|
993e49db48 | ||
|
|
d458ddba55 | ||
|
|
bd5747b7f6 | ||
|
|
a335130ad4 | ||
|
|
9558513190 | ||
|
|
27a3015d4f | ||
|
|
f751f9afa8 | ||
|
|
2e2b31aa79 | ||
|
|
135d002f02 | ||
|
|
a45ede9348 | ||
|
|
e4b2e5022f | ||
|
|
286010ce90 | ||
|
|
13eb89746b | ||
|
|
d2f639c57f | ||
|
|
ad587606b7 | ||
|
|
9fd5e2057d | ||
|
|
8f63b850fc | ||
|
|
199d04b663 | ||
|
|
658741be52 | ||
|
|
f1bcc756d3 | ||
|
|
cdcb1de3d4 | ||
|
|
7d11a6207a | ||
|
|
e608ad24c2 | ||
|
|
4fe382398e | ||
|
|
b6546f3ae3 | ||
|
|
4620eade58 | ||
|
|
23a328f12d | ||
|
|
83905dd6a6 | ||
|
|
3eb4eb3c09 | ||
|
|
2eba9a8d72 | ||
|
|
9b17e7a7e2 | ||
|
|
3eb9e4a035 | ||
|
|
3edc97eb38 | ||
|
|
cb0208af4d | ||
|
|
cdd311f741 | ||
|
|
8543325d59 | ||
|
|
a1a677a3e2 | ||
|
|
3705465ef2 | ||
|
|
c80999839b | ||
|
|
936212e684 | ||
|
|
1cc39fb89b | ||
|
|
37d3a953c8 | ||
|
|
5a1a23d9ac | ||
|
|
6cb359b2d0 | ||
|
|
8bd89d1e63 | ||
|
|
f111ac7cf2 | ||
|
|
f6e000ab97 | ||
|
|
29869c93b2 | ||
|
|
3aae5ce1de | ||
|
|
e379cf6127 | ||
|
|
0c23cb5ca8 | ||
|
|
d26ba27069 | ||
|
|
e918178694 | ||
|
|
3d075bdd65 | ||
|
|
4a3b8af6af | ||
|
|
2743492076 | ||
|
|
6ebc453e4b | ||
|
|
75bd950b9b | ||
|
|
0b0c4eb8c0 | ||
|
|
e7dbc874bb | ||
|
|
17426f1dbb | ||
|
|
e00ce48517 | ||
|
|
cba1caa5be | ||
|
|
5f6b073cb6 | ||
|
|
51647a5017 | ||
|
|
fae2ceab39 | ||
|
|
553346629a | ||
|
|
726f48bc33 | ||
|
|
397793064d | ||
|
|
534af3c1a0 | ||
|
|
f551a700fe | ||
|
|
d0c737779a | ||
|
|
497b38ddbf | ||
|
|
cdad45096b | ||
|
|
f8aedca08e | ||
|
|
490ca1d74c | ||
|
|
cf9ddf3960 | ||
|
|
61498037f3 | ||
|
|
1e499fd12f | ||
|
|
a9fc5622cd | ||
|
|
777a91abc7 | ||
|
|
372f939a67 | ||
|
|
716229719a | ||
|
|
b57b160660 | ||
|
|
40c52a31c9 | ||
|
|
05c16e4c70 | ||
|
|
7a7c4c28c2 | ||
|
|
8e2ab40b4c | ||
|
|
bcef73c2e0 | ||
|
|
f0a109245b | ||
|
|
c6c30f25a2 | ||
|
|
8036aaa985 | ||
|
|
a56dd5ca87 | ||
|
|
40ac0f4e89 | ||
|
|
1aa9aa97ac | ||
|
|
96a75a7f7f | ||
|
|
5009bd4e6a | ||
|
|
62ea82a2ba | ||
|
|
fa55062510 | ||
|
|
4b195c67cb | ||
|
|
f36aa09a81 | ||
|
|
e0f16548cf | ||
|
|
577971c7a9 | ||
|
|
6bbd941158 | ||
|
|
b92dd19a4c | ||
|
|
13f3a8cf8a | ||
|
|
60da8116be | ||
|
|
1b7c873ea5 | ||
|
|
b18ecfdffd | ||
|
|
da286329f7 | ||
|
|
db69603b5d | ||
|
|
a2b73bf979 | ||
|
|
dc503e3406 | ||
|
|
ab3e0b87c6 | ||
|
|
7751ce3ae0 | ||
|
|
796e98be10 | ||
|
|
9c266e7995 | ||
|
|
b4ae13fe8a | ||
|
|
8ffad4cc6f | ||
|
|
f341e02fb7 | ||
|
|
15e52a8b88 | ||
|
|
84717b95f7 | ||
|
|
b1d1e92dbb | ||
|
|
cca35ec687 | ||
|
|
95fc9d6c3c | ||
|
|
cb057968ee | ||
|
|
deed8ac6c9 | ||
|
|
fe44f8e369 | ||
|
|
e517232172 | ||
|
|
28310a88f5 | ||
|
|
3252871ed5 | ||
|
|
2740b5e300 | ||
|
|
a46faebb67 | ||
|
|
16a4c321c4 | ||
|
|
056ef84817 | ||
|
|
820d76990a | ||
|
|
01e4a7fd79 | ||
|
|
8d4f87641d | ||
|
|
afb248c57c | ||
|
|
62871c1bdd | ||
|
|
c6be427883 | ||
|
|
7873ec2b67 | ||
|
|
64e6b492ab | ||
|
|
c1cd893a4a | ||
|
|
8a1e033efa | ||
|
|
b2c974a684 | ||
|
|
57e476988e | ||
|
|
6262f775d5 | ||
|
|
18ae665cd1 | ||
|
|
029a76f8a2 | ||
|
|
e0e3f7dfec | ||
|
|
2220ceb9d9 | ||
|
|
a28698da36 | ||
|
|
9307835d2d | ||
|
|
cfe167b639 | ||
|
|
64d3b36b28 | ||
|
|
b433a7b816 | ||
|
|
17643bf13b | ||
|
|
a444a96dc9 | ||
|
|
41a7560e76 | ||
|
|
90be2a0e53 | ||
|
|
6c7bb85aa3 | ||
|
|
fe898d824b | ||
|
|
ac8a972c6e | ||
|
|
1e691005c7 | ||
|
|
0b0e25b121 | ||
|
|
afff792ecc | ||
|
|
7f47e50674 | ||
|
|
bca886cfb9 | ||
|
|
55216f5583 | ||
|
|
f8220ca554 | ||
|
|
3cb674f095 | ||
|
|
5977016075 | ||
|
|
eefd7bd37a | ||
|
|
019025ab8a | ||
|
|
36f1183d6c | ||
|
|
caf87def13 | ||
|
|
729a9c0864 | ||
|
|
f004b72ba2 | ||
|
|
e83a4692c5 | ||
|
|
acf811c79a | ||
|
|
733b4ff805 | ||
|
|
756b926f6f | ||
|
|
5164a44ee8 | ||
|
|
cfebd0eeb9 | ||
|
|
5212b33b47 | ||
|
|
6120f90dcb | ||
|
|
0a76eb81e6 | ||
|
|
192509f762 | ||
|
|
de09571077 | ||
|
|
8f5c326758 | ||
|
|
91d3f331e5 | ||
|
|
ace4157a14 | ||
|
|
83b97d274f | ||
|
|
0d3ea22641 | ||
|
|
eb634d62ce | ||
|
|
6353e7b1be | ||
|
|
286c340f01 | ||
|
|
055b79c9f2 | ||
|
|
29a9297452 | ||
|
|
929200d53d | ||
|
|
6e9b1551e7 | ||
|
|
1547ec2067 | ||
|
|
f7dce21246 | ||
|
|
3d0634de8d | ||
|
|
64396c1de6 | ||
|
|
9f5b822e33 | ||
|
|
eac9f78dfa |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ obj/
|
||||
jni/libspeex/.deps/
|
||||
pkcs11.password
|
||||
dev.keystore
|
||||
maps.key
|
||||
|
||||
@@ -16,9 +16,6 @@ repositories {
|
||||
includeGroupByRegex "org\\.signal.*"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url "https://www.jitpack.io"
|
||||
}
|
||||
|
||||
google()
|
||||
mavenCentral()
|
||||
@@ -29,10 +26,6 @@ repositories {
|
||||
jcenter {
|
||||
content {
|
||||
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
|
||||
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
|
||||
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
|
||||
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
|
||||
includeVersion "com.google.android", "flexbox", "0.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +50,8 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1101
|
||||
def canonicalVersionName = "5.45.3"
|
||||
def canonicalVersionCode = 1163
|
||||
def canonicalVersionName = "6.2.2"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -158,6 +151,9 @@ android {
|
||||
exclude 'signal_jni.dll'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode canonicalVersionCode * postFixSize
|
||||
@@ -171,6 +167,8 @@ android {
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
project.ext.set("archivesBaseName", "Signal");
|
||||
|
||||
manifestPlaceholders = [mapsKey:"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"]
|
||||
|
||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
||||
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
|
||||
@@ -178,12 +176,12 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\"}"
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\", \"Development\"}"
|
||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\", \"https://sfu.staging.test.voip.signal.org\"}"
|
||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||
buildConfigField "String[]", "SIGNAL_SERVICE_IPS", service_ips
|
||||
@@ -195,12 +193,14 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"e5eaa62da3514e8b37ccabddb87e52e7f319ccf5120a13f9e1b42b87ec9dd3dd\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
|
||||
buildConfigField "String", "CDSI_MRENCLAVE", "\"ef4787a56a154ac6d009138cac17155acd23cfe4329281252365dd7c252e7fbf\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"e18376436159cda3ad7a45d9320e382e4a497f26b0dca34d8eab0bd0139483b5\", " +
|
||||
"\"3a485adb56e2058ef7737764c738c4069dd62bc457637eafb6bbce1ce29ddb89\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"AMhf5ywVwITZMsff/eCyudZx9JDmkkkbV6PInzG4p8x3VqVJSFiMvnvlEKWuRob/1eaIetR31IYeAbm0NdOuHH8Qi+Rexi1wLlpzIo1gstHWBfZzy1+qHRV5A4TqPp15YzBPm0WSggW6PbSn+F4lf57VCnHF7p8SvzAA2ZZJPYJURt8X7bbg+H3i+PEjH9DXItNEqs2sNcug37xZQDLm7X36nOoGPs54XsEGzPdEV+itQNGUFEjY6X9Uv+Acuks7NpyGvCoKxGwgKgE5XyJ+nNKlyHHOLb6N1NuHyBrZrgtY/JYJHRooo5CEqYKBqdFnmbTVGEkCvJKxLnjwKWf+fEPoWeQFj5ObDjcKMZf2Jm2Ae69x+ikU5gBXsRmoF94GXTLfN0/vLt98KDPnxwAQL9j5V1jGOY8jQl6MLxEs56cwXN0dqCnImzVH3TZT1cJ8SW1BRX6qIVxEzjsSGx3yxF3suAilPMqGRp4ffyopjMD1JXiKR2RwLKzizUe5e8XyGOy9fplzhw3jVzTRyUZTRSZKkMLWcQ/gv0E4aONNqs4P\""
|
||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||
@@ -231,7 +231,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
||||
@@ -263,6 +263,8 @@ android {
|
||||
testProguardFiles 'proguard/proguard-automation.pro',
|
||||
'proguard/proguard.cfg'
|
||||
|
||||
manifestPlaceholders = [mapsKey:getMapsKey()]
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||
}
|
||||
|
||||
@@ -344,12 +346,15 @@ android {
|
||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_CDSI_URL", "\"https://cdsi.staging.signal.org\""
|
||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"74778bb0f93ae1f78c26e67152bab0bbeb693cd56d1bb9b4e9244157acc58081\""
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"39963b736823d5780be96ab174869a9499d56d66497aa8f9b2244f777ebc366b\", " +
|
||||
"\"9dbc6855c198e04f21b5cc35df839fdcd51b53658454dfa3f817afefaffc95ef\", " +
|
||||
"\"45627094b2ea4a66f4cf0b182858a8dcf4b8479122c3820fe7fd0551a6d4cf5c\")"
|
||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] { new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\") }"
|
||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
@@ -405,10 +410,11 @@ dependencies {
|
||||
|
||||
implementation (libs.androidx.appcompat) {
|
||||
version {
|
||||
strictly '1.2.0'
|
||||
strictly '1.5.1'
|
||||
}
|
||||
}
|
||||
implementation libs.androidx.window
|
||||
implementation libs.androidx.window.window
|
||||
implementation libs.androidx.window.java
|
||||
implementation libs.androidx.recyclerview
|
||||
implementation libs.material.material
|
||||
implementation libs.androidx.legacy.support
|
||||
@@ -421,7 +427,9 @@ dependencies {
|
||||
implementation libs.androidx.multidex
|
||||
implementation libs.androidx.navigation.fragment.ktx
|
||||
implementation libs.androidx.navigation.ui.ktx
|
||||
implementation libs.androidx.lifecycle.extensions
|
||||
implementation libs.androidx.lifecycle.viewmodel.ktx
|
||||
implementation libs.androidx.lifecycle.livedata.ktx
|
||||
implementation libs.androidx.lifecycle.process
|
||||
implementation libs.androidx.lifecycle.viewmodel.savedstate
|
||||
implementation libs.androidx.lifecycle.common.java8
|
||||
implementation libs.androidx.lifecycle.reactivestreams.ktx
|
||||
@@ -458,6 +466,9 @@ dependencies {
|
||||
implementation project(':donations')
|
||||
implementation project(':contacts')
|
||||
implementation project(':qr')
|
||||
implementation project(':sms-exporter')
|
||||
implementation project(':sticky-header-grid')
|
||||
implementation project(':photoview')
|
||||
|
||||
implementation libs.libsignal.android
|
||||
implementation libs.google.protobuf.javalite
|
||||
@@ -478,7 +489,6 @@ dependencies {
|
||||
implementation libs.emilsjolander.stickylistheaders
|
||||
implementation libs.jpardogo.materialtabstrip
|
||||
implementation libs.apache.httpclient.android
|
||||
implementation libs.photoview
|
||||
implementation libs.glide.glide
|
||||
implementation libs.roundedimageview
|
||||
implementation libs.materialish.progress
|
||||
@@ -487,12 +497,10 @@ dependencies {
|
||||
implementation libs.google.zxing.android.integration
|
||||
implementation libs.time.duration.picker
|
||||
implementation libs.google.zxing.core
|
||||
implementation libs.google.flexbox
|
||||
implementation (libs.subsampling.scale.image.view) {
|
||||
exclude group: 'com.android.support', module: 'support-annotations'
|
||||
}
|
||||
implementation (libs.numberpickerview) {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
}
|
||||
implementation (libs.android.tooltips) {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
}
|
||||
@@ -501,15 +509,9 @@ dependencies {
|
||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||
}
|
||||
implementation libs.stream
|
||||
implementation (libs.colorpicker) {
|
||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||
exclude group: 'com.android.support', module: 'recyclerview-v7'
|
||||
}
|
||||
|
||||
implementation libs.lottie
|
||||
|
||||
implementation libs.stickyheadergrid
|
||||
|
||||
implementation libs.signal.android.database.sqlcipher
|
||||
implementation libs.androidx.sqlite
|
||||
|
||||
@@ -536,6 +538,7 @@ dependencies {
|
||||
force = true
|
||||
}
|
||||
testImplementation testLibs.hamcrest.hamcrest
|
||||
testImplementation testLibs.mockk
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
@@ -544,6 +547,13 @@ dependencies {
|
||||
androidTestImplementation testLibs.androidx.test.core
|
||||
androidTestImplementation testLibs.androidx.test.core.ktx
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
|
||||
androidTestImplementation testLibs.mockito.android
|
||||
androidTestImplementation testLibs.mockito.kotlin
|
||||
androidTestImplementation testLibs.square.okhttp.mockserver
|
||||
|
||||
instrumentationImplementation (libs.androidx.fragment.testing) {
|
||||
exclude group: 'androidx.test', module: 'core'
|
||||
}
|
||||
|
||||
testImplementation testLibs.espresso.core
|
||||
|
||||
@@ -555,7 +565,7 @@ dependencies {
|
||||
implementation libs.rxjava3.rxkotlin
|
||||
implementation libs.rxdogtag
|
||||
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.1'
|
||||
androidTestUtil testLibs.androidx.test.orchestrator
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
@@ -634,3 +644,11 @@ def getDateSuffix() {
|
||||
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
|
||||
return formattedDate
|
||||
}
|
||||
|
||||
def getMapsKey() {
|
||||
def mapKey = file("${project.rootDir}/maps.key")
|
||||
if (mapKey.exists()) {
|
||||
return mapKey.readLines()[0]
|
||||
}
|
||||
return "AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<issue id="VectorRaster" severity="error" />
|
||||
<issue id="ButtonOrder" severity="error" />
|
||||
<issue id="ExtraTranslation" severity="warning" />
|
||||
<issue id="UnspecifiedImmutableFlag" severity="error" />
|
||||
|
||||
<!-- Custom lints -->
|
||||
<issue id="LogNotSignal" severity="error" />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
|
||||
/**
|
||||
* Application context for running instrumentation tests (aka androidTests).
|
||||
*/
|
||||
class SignalInstrumentationApplicationContext : ApplicationContext() {
|
||||
override fun initializeAppDependencies() {
|
||||
val default = ApplicationDependencyProvider(this)
|
||||
ApplicationDependencies.init(this, InstrumentationApplicationDependencyProvider(this, default))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.mock
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountRepository
|
||||
import org.thoughtcrime.securesms.registration.VerifyAccountResponseProcessor
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.MockProvider
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsSize
|
||||
import org.thoughtcrime.securesms.testing.connectionFailure
|
||||
import org.thoughtcrime.securesms.testing.failure
|
||||
import org.thoughtcrime.securesms.testing.parsedRequestBody
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.thoughtcrime.securesms.testing.timeout
|
||||
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.MismatchedDevices
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyState
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ChangeNumberViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private lateinit var viewModel: ChangeNumberViewModel
|
||||
private lateinit var kbsRepository: KbsRepository
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
ApplicationDependencies.getSignalServiceAccountManager().setSoTimeoutMillis(1000)
|
||||
kbsRepository = mock()
|
||||
ThreadUtil.runOnMainSync {
|
||||
viewModel = ChangeNumberViewModel(
|
||||
localNumber = harness.self.requireE164(),
|
||||
changeNumberRepository = ChangeNumberRepository(),
|
||||
savedState = SavedStateHandle(),
|
||||
password = SignalStore.account().servicePassword!!,
|
||||
verifyAccountRepository = VerifyAccountRepository(harness.application),
|
||||
kbsRepository = kbsRepository
|
||||
)
|
||||
|
||||
viewModel.setNewCountry(1)
|
||||
viewModel.setNewNationalNumber("5555550102")
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenOnlyPrimaryAndNoRegLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a server error, this means the server ack our request and rejected it. In this
|
||||
* case we know the change *did not* take on the server and can reset to a clean state.
|
||||
*/
|
||||
@Test
|
||||
fun testChangeNumber_givenServerFailedApiCall() {
|
||||
// GIVEN
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { MockResponse().failure(500) },
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val processor: VerifyAccountResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs true
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
|
||||
* number on the server side. We have to do a whoami call to query the server for our details and then
|
||||
* respond accordingly.
|
||||
*
|
||||
* In this case, the whoami is our old details, so we can know the change *did not* take on the server
|
||||
* and can reset to a clean state.
|
||||
*/
|
||||
@Test
|
||||
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToServer() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { MockResponse().connectionFailure() },
|
||||
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, oldPni, oldE164)) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val processor: VerifyAccountResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs false
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().isChangeNumberLocked assertIs false
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* If we encounter a non-server error like a timeout or bad SSL, we do not know the state of our change
|
||||
* number on the server side. We have to do a whoami call to query the server for our details and then
|
||||
* respond accordingly.
|
||||
*
|
||||
* In this case, the whoami is our new details, so we can know the change *did* take on the server
|
||||
* and need to keep the app in a locked state. The test then uses the ChangeNumberLockActivity to unlock
|
||||
* and apply the pending state after confirming the change on the server.
|
||||
*/
|
||||
@Test
|
||||
fun testChangeNumber_givenNetworkFailedApiCallEnRouteToClient() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val oldPni = Recipient.self().requirePni()
|
||||
val oldE164 = Recipient.self().requireE164()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
MockResponse().timeout()
|
||||
},
|
||||
Get("/v1/accounts/whoami") { MockResponse().success(MockProvider.createWhoAmIResponse(aci, newPni, "+15555550102")) },
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val processor: VerifyAccountResponseProcessor = viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet()
|
||||
|
||||
// THEN
|
||||
processor.isServerSentError() assertIs false
|
||||
Recipient.self().requireE164() assertIs oldE164
|
||||
Recipient.self().requirePni() assertIs oldPni
|
||||
SignalStore.misc().isChangeNumberLocked assertIs true
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNotNull()
|
||||
|
||||
// WHEN AGAIN Processing lock
|
||||
val scenario = harness.launchActivity<ChangeNumberLockActivity>()
|
||||
scenario.onActivity {}
|
||||
ThreadUtil.sleep(500)
|
||||
|
||||
// THEN AGAIN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenOnlyPrimaryAndRegistrationLock() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenMismatchedDevicesOnFirstCall() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v1/devices") { MockResponse().success(MockProvider.primaryOnlyDeviceList) },
|
||||
Put("/v1/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.deviceMessages.isEmpty()) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Get("/v2/keys/$aci/2") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChangeNumber_givenRegLockAndMismatchedDevicesOnFirstTwoCalls() {
|
||||
// GIVEN
|
||||
val aci = Recipient.self().requireServiceId()
|
||||
val newPni = ServiceId.from(UUID.randomUUID())
|
||||
|
||||
lateinit var changeNumberRequest: ChangePhoneNumberRequest
|
||||
lateinit var setPreKeysRequest: PreKeyState
|
||||
|
||||
MockProvider.mockGetRegistrationLockStringFlow(kbsRepository)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/number") { r ->
|
||||
changeNumberRequest = r.parsedRequestBody()
|
||||
if (changeNumberRequest.registrationLock.isNullOrEmpty()) {
|
||||
MockResponse().failure(423, MockProvider.lockedFailure)
|
||||
} else if (changeNumberRequest.deviceMessages.isEmpty()) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else if (changeNumberRequest.deviceMessages.size == 1) {
|
||||
MockResponse().failure(
|
||||
409,
|
||||
MismatchedDevices().apply {
|
||||
missingDevices = listOf(2, 3)
|
||||
extraDevices = emptyList()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
MockResponse().success(MockProvider.createVerifyAccountResponse(aci, newPni))
|
||||
}
|
||||
},
|
||||
Get("/v2/keys/$aci/2") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 2))
|
||||
},
|
||||
Get("/v2/keys/$aci/3") {
|
||||
MockResponse().success(MockProvider.createPreKeyResponse(deviceId = 3))
|
||||
},
|
||||
Put("/v2/keys") { r ->
|
||||
setPreKeysRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Get("/v1/certificate/delivery") { MockResponse().success(MockProvider.senderCertificate) }
|
||||
)
|
||||
|
||||
// WHEN
|
||||
viewModel.verifyCodeWithoutRegistrationLock("123456").blockingGet().also { processor ->
|
||||
processor.registrationLock() assertIs true
|
||||
Recipient.self().requirePni() assertIsNot newPni
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
|
||||
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock("pin").blockingGet().resultOrThrow
|
||||
|
||||
// THEN
|
||||
assertSuccess(newPni, changeNumberRequest, setPreKeysRequest)
|
||||
}
|
||||
|
||||
private fun assertSuccess(newPni: ServiceId, changeNumberRequest: ChangePhoneNumberRequest, setPreKeysRequest: PreKeyState) {
|
||||
val pniProtocolStore = ApplicationDependencies.getProtocolStore().pni()
|
||||
val pniMetadataStore = SignalStore.account().pniPreKeys
|
||||
|
||||
Recipient.self().requireE164() assertIs "+15555550102"
|
||||
Recipient.self().requirePni() assertIs newPni
|
||||
|
||||
SignalStore.account().pniRegistrationId assertIs changeNumberRequest.pniRegistrationIds["1"]!!
|
||||
SignalStore.account().pniIdentityKey.publicKey assertIs changeNumberRequest.pniIdentityKey
|
||||
pniMetadataStore.activeSignedPreKeyId assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.keyId
|
||||
|
||||
val activeSignedPreKey: SignedPreKeyRecord = pniProtocolStore.loadSignedPreKey(pniMetadataStore.activeSignedPreKeyId)
|
||||
activeSignedPreKey.keyPair.publicKey assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.publicKey
|
||||
activeSignedPreKey.signature assertIs changeNumberRequest.devicePniSignedPrekeys["1"]!!.signature
|
||||
|
||||
setPreKeysRequest.signedPreKey.publicKey assertIs activeSignedPreKey.keyPair.publicKey
|
||||
setPreKeysRequest.preKeys assertIsSize 100
|
||||
|
||||
SignalStore.misc().pendingChangeNumberMetadata.assertIsNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.thoughtcrime.securesms.attachments.PointerAttachment
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.releasechannel.ReleaseChannel
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Helper test for rendering conversation items for preview.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@Ignore("For testing/previewing manually, no assertions")
|
||||
class ConversationItemPreviewer {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
@Test
|
||||
fun testShowLongName() {
|
||||
val other: Recipient = Recipient.resolved(harness.others.first())
|
||||
|
||||
SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("Seef", "$$$"))
|
||||
|
||||
insertFailedMediaMessage(other = other, attachmentCount = 1)
|
||||
insertFailedMediaMessage(other = other, attachmentCount = 2)
|
||||
insertFailedMediaMessage(other = other, body = "Test", attachmentCount = 1)
|
||||
// insertFailedOutgoingMediaMessage(other = other, body = "Test", attachmentCount = 1)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
// insertMediaMessage(other = other)
|
||||
|
||||
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
|
||||
scenario.onActivity {
|
||||
}
|
||||
|
||||
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
|
||||
// ThreadUtil.sleep(45000)
|
||||
}
|
||||
|
||||
private fun insertMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
|
||||
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
|
||||
attachment()
|
||||
}
|
||||
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = System.currentTimeMillis(),
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
)
|
||||
|
||||
SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
|
||||
private fun insertFailedMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
|
||||
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
|
||||
attachment()
|
||||
}
|
||||
|
||||
val message = IncomingMediaMessage(
|
||||
from = other.id,
|
||||
body = body,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = System.currentTimeMillis(),
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
)
|
||||
|
||||
val insert = SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(other)).get()
|
||||
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(insert.messageId).forEachIndexed { index, attachment ->
|
||||
// if (index != 1) {
|
||||
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, insert.messageId)
|
||||
// } else {
|
||||
// SignalDatabase.attachments.setTransferState(insert.messageId, attachment, TRANSFER_PROGRESS_STARTED)
|
||||
// }
|
||||
}
|
||||
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
|
||||
private fun insertFailedOutgoingMediaMessage(other: Recipient, body: String? = null, attachmentCount: Int = 1) {
|
||||
val attachments: List<SignalServiceAttachmentPointer> = (0 until attachmentCount).map {
|
||||
attachment()
|
||||
}
|
||||
|
||||
val message = OutgoingMediaMessage(
|
||||
other,
|
||||
body,
|
||||
PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
System.currentTimeMillis(),
|
||||
-1,
|
||||
0,
|
||||
false,
|
||||
ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
StoryType.NONE,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptySet(),
|
||||
emptySet(),
|
||||
null
|
||||
)
|
||||
|
||||
val insert = SignalDatabase.mms.insertMessageOutbox(
|
||||
OutgoingSecureMediaMessage(message),
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(other),
|
||||
false,
|
||||
null
|
||||
)
|
||||
|
||||
SignalDatabase.attachments.getAttachmentsForMessage(insert).forEachIndexed { index, attachment ->
|
||||
SignalDatabase.attachments.setTransferProgressPermanentFailure(attachment.attachmentId, insert)
|
||||
}
|
||||
|
||||
ThreadUtil.sleep(1)
|
||||
}
|
||||
|
||||
private fun attachment(): SignalServiceAttachmentPointer {
|
||||
return SignalServiceAttachmentPointer(
|
||||
ReleaseChannel.CDN_NUMBER,
|
||||
SignalServiceAttachmentRemoteId.from(""),
|
||||
"image/webp",
|
||||
null,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
1024,
|
||||
1024,
|
||||
Optional.empty(),
|
||||
Optional.of("/not-there.jpg"),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
/**
|
||||
* Android test to help show SNC dialog quickly with custom data to make sure it displays properly.
|
||||
*/
|
||||
@Ignore("For testing/previewing manually, no assertions")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SafetyNumberChangeDialogPreviewer {
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
@@ -116,6 +117,7 @@ class MmsDatabaseTest_stories {
|
||||
assertTrue(messageAfterMark.incomingStoryViewedAtTimestamp > 0)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun given5ViewedStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectLatestViewedFirst() {
|
||||
// GIVEN
|
||||
@@ -257,12 +259,13 @@ class MmsDatabaseTest_stories {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasSelfReplyInGroupStory(groupStoryId)
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun givenAGroupStoryWithAReplyFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
|
||||
// GIVEN
|
||||
@@ -281,14 +284,14 @@ class MmsDatabaseTest_stories {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasSelfReplyInGroupStory(groupStoryId)
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
|
||||
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
|
||||
// GIVEN
|
||||
val groupStoryId = MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
@@ -306,10 +309,10 @@ class MmsDatabaseTest_stories {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasSelfReplyInGroupStory(groupStoryId)
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -334,7 +337,7 @@ class MmsDatabaseTest_stories {
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasSelfReplyInGroupStory(groupStoryId)
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
|
||||
@@ -18,6 +18,77 @@ class RecipientDatabaseTest {
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIQueryAllContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results = SignalDatabase.recipients.queryAllContacts("Hidden")!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIGetSignalContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results: MutableList<RecipientId> = SignalDatabase.recipients.getSignalContacts(false)?.use {
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (it.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}!!
|
||||
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(hiddenRecipient in results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIQuerySignalContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results = SignalDatabase.recipients.querySignalContacts("Hidden", false)!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIQueryNonGroupContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results = SignalDatabase.recipients.queryNonGroupContacts("Hidden", false)!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAHiddenRecipient_whenIGetNonGroupContacts_thenIDoNotExpectHiddenToBeReturned() {
|
||||
val hiddenRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(hiddenRecipient, ProfileName.fromParts("Hidden", "Person"))
|
||||
SignalDatabase.recipients.markHidden(hiddenRecipient)
|
||||
|
||||
val results: MutableList<RecipientId> = SignalDatabase.recipients.getNonGroupContacts(false)?.use {
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (it.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientDatabase.ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}!!
|
||||
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(hiddenRecipient in results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIQueryAllContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -26,9 +25,7 @@ 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
|
||||
@@ -46,7 +43,7 @@ import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
class RecipientDatabaseTest_getAndPossiblyMerge {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
private lateinit var identityDatabase: IdentityDatabase
|
||||
@@ -93,7 +90,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** If all you have is an ACI, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, null)
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
@@ -103,7 +100,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(null, E164_A)
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
@@ -113,7 +110,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** With high trust, you can associate an ACI-e164 pair. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
@@ -129,7 +126,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -140,9 +137,9 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** Basically the ‘change number’ case. Update the existing user. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -159,7 +156,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -170,10 +167,10 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** We never change the ACI of an existing row. New ACI = new person. Take the e164 from the current holder. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
recipientDatabase.setPni(existingId, PNI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_B, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A)
|
||||
recipientDatabase.setPni(retrievedId, PNI_A)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
@@ -195,9 +192,9 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_B, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -216,9 +213,9 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** If your ACI and e164 match, you’re good. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -229,36 +226,29 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** Merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, null)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(null, E164_A)
|
||||
val mergedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingAciId, mergedId)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
val retrievedRecipient = Recipient.resolved(mergedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
assertEquals(mergedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assertFalse(changeNumberListener.numberChangeWasEnqueued)
|
||||
// TODO [greyson] Change number
|
||||
}
|
||||
|
||||
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge], but with a number change. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(null, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -268,20 +258,16 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
// TODO [greyson] Change number
|
||||
}
|
||||
|
||||
/** No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A)
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_B, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -292,7 +278,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
assertEquals(ACI_B, existingRecipient2.requireServiceId())
|
||||
assertFalse(existingRecipient2.hasE164())
|
||||
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
// TODO [greyson] Change number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,10 +287,10 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
*/
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_mergeAndPhoneNumberChange() {
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(null, E164_A)
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -326,10 +312,10 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_B, E164_A)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, null)
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
assertEquals(existingId2, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -350,9 +336,9 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B, changeSelf = false)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, changeSelf = false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -369,9 +355,9 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B, changeSelf = true)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, changeSelf = true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
@@ -382,20 +368,16 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
/** Verifying a case where a change number job is expected to be enqueued. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A)
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_B)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
// TODO [greyson] Change number
|
||||
}
|
||||
|
||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@@ -445,7 +427,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergeLegacy(ACI_A, E164_A, true)
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
||||
assertEquals(recipientIdAci, retrievedId)
|
||||
|
||||
@@ -572,7 +554,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun getAndPossiblyMerge_noArgs_invalid() {
|
||||
recipientDatabase.getAndPossiblyMergeLegacy(null, null, true)
|
||||
recipientDatabase.getAndPossiblyMerge(null, null, true)
|
||||
}
|
||||
|
||||
private fun ensureDbEmpty() {
|
||||
@@ -587,7 +569,7 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
}
|
||||
|
||||
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())
|
||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty(), false, false)
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
@@ -629,24 +611,6 @@ class RecipientDatabaseTest_getAndPossiblyMergeLegacy {
|
||||
val threadId: Long
|
||||
)
|
||||
|
||||
private class ChangeNumberListener {
|
||||
|
||||
var numberChangeWasEnqueued = false
|
||||
private set
|
||||
|
||||
fun waitForJobManager() {
|
||||
ApplicationDependencies.getJobManager().flush()
|
||||
ThreadUtil.sleep(500)
|
||||
}
|
||||
|
||||
fun enqueue() {
|
||||
ApplicationDependencies.getJobManager().addListener(
|
||||
{ job -> job.factoryKey == RecipientChangedNumberJob.KEY },
|
||||
{ _, _ -> numberChangeWasEnqueued = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -1,670 +0,0 @@
|
||||
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
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
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_getAndPossiblyMergePnp {
|
||||
|
||||
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
|
||||
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)
|
||||
SignalStore.account().setPni(localPni)
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If both the ACI and E164 map to no one
|
||||
// ==============================================================
|
||||
|
||||
/** If all you have is an ACI, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, null)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(null, E164_A)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
assertFalse(recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** With high trust, you can associate an ACI-e164 pair. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If the ACI maps to an existing user, but the E164 doesn't
|
||||
// ==============================================================
|
||||
|
||||
/** You can associate an e164 with an existing ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** Basically the ‘change number’ case. Update the existing user. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If the E164 maps to an existing user, but the ACI doesn't
|
||||
// ==============================================================
|
||||
|
||||
/** You can associate an e164 with an existing ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** We never change the ACI of an existing row. New ACI = new person. Take the e164 from the current holder. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
recipientDatabase.setPni(existingId, PNI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_B, E164_A)
|
||||
recipientDatabase.setPni(retrievedId, PNI_A)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(ACI_A, existingRecipient.requireServiceId())
|
||||
assertFalse(existingRecipient.hasE164())
|
||||
}
|
||||
|
||||
/** We never want to remove the e164 of our own contact entry. Leave the e164 alone. */
|
||||
@Ignore("Change self isn't implemented yet!")
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_e164BelongsToLocalUser() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_B, E164_A)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_B, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(ACI_A, existingRecipient.requireServiceId())
|
||||
assertEquals(E164_A, existingRecipient.requireE164())
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If both the ACI and E164 map to an existing user
|
||||
// ==============================================================
|
||||
|
||||
/** If your ACI and e164 match, you’re good. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** Merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, null)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMergePnp(null, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
// TODO: Recipient remapping!
|
||||
// val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
// assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assertFalse(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge], but with a number change. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMergePnp(null, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
// TODO: Recipient remapping!
|
||||
// val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
// assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
// TODO: Change number!
|
||||
// changeNumberListener.waitForJobManager()
|
||||
// assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_B, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||
assertEquals(ACI_B, existingRecipient2.requireServiceId())
|
||||
assertFalse(existingRecipient2.hasE164())
|
||||
|
||||
// TODO: Change number!
|
||||
// assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/**
|
||||
* Another case that results in a merge. Nothing strictly new here, but this case is called out because it’s a merge but *also* an E164 change,
|
||||
* which clients may need to know for UX purposes.
|
||||
*/
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_mergeAndPhoneNumberChange() {
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergePnp(null, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
assertFalse(recipientDatabase.getByE164(E164_B).isPresent)
|
||||
|
||||
// TODO: Recipient remapping!
|
||||
// val recipientWithId2 = Recipient.resolved(existingId2)
|
||||
// assertEquals(retrievedId, recipientWithId2.id)
|
||||
}
|
||||
|
||||
/** We never want to remove the e164 of our own contact entry. Leave the e164 alone. */
|
||||
@Ignore("Change self isn't implemented yet!")
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_e164BelongsToLocalUser() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_B.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_B, E164_A)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, null)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
assertEquals(existingId2, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val recipientWithId1 = Recipient.resolved(existingId1)
|
||||
assertEquals(ACI_B, recipientWithId1.requireServiceId())
|
||||
assertEquals(E164_A, recipientWithId1.requireE164())
|
||||
}
|
||||
|
||||
/** This is a case where normally we'd update the E164 of a user, but here the changeSelf flag is disabled, so we shouldn't. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_changeSelfFalse() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B, changeSelf = false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** This is a case where we're changing our own number, and it's allowed because changeSelf = true. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciBelongsToLocalUser_changeSelfTrue() {
|
||||
val dataSet = KeyValueDataSet().apply {
|
||||
putString(AccountValues.KEY_E164, E164_A)
|
||||
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||
}
|
||||
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B, changeSelf = true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** Verifying a case where a change number job is expected to be enqueued. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMergePnp(ACI_A, E164_B)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
// TODO: Change number!
|
||||
// changeNumberListener.waitForJobManager()
|
||||
// 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. */
|
||||
@Ignore("This level of merging isn't implemented yet!")
|
||||
@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.getAndPossiblyMergePnp(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
|
||||
// ==============================================================
|
||||
|
||||
@Test
|
||||
fun createByE164SanityCheck() {
|
||||
// GIVEN one recipient
|
||||
val recipientId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
// WHEN I retrieve one by E164
|
||||
val possible: Optional<RecipientId> = recipientDatabase.getByE164(E164_A)
|
||||
|
||||
// THEN I get it back, and it has the properties I expect
|
||||
assertTrue(possible.isPresent)
|
||||
assertEquals(recipientId, possible.get())
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertTrue(recipient.e164.isPresent)
|
||||
assertEquals(E164_A, recipient.e164.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createByUuidSanityCheck() {
|
||||
// GIVEN one recipient
|
||||
val recipientId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
// WHEN I retrieve one by UUID
|
||||
val possible: Optional<RecipientId> = recipientDatabase.getByServiceId(ACI_A)
|
||||
|
||||
// THEN I get it back, and it has the properties I expect
|
||||
assertTrue(possible.isPresent)
|
||||
assertEquals(recipientId, possible.get())
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertTrue(recipient.serviceId.isPresent)
|
||||
assertEquals(ACI_A, recipient.serviceId.get())
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun getAndPossiblyMerge_noArgs_invalid() {
|
||||
recipientDatabase.getAndPossiblyMergePnp(null, null, true)
|
||||
}
|
||||
|
||||
private fun ensureDbEmpty() {
|
||||
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
|
||||
private set
|
||||
|
||||
fun waitForJobManager() {
|
||||
ApplicationDependencies.getJobManager().flush()
|
||||
ThreadUtil.sleep(500)
|
||||
}
|
||||
|
||||
fun enqueue() {
|
||||
ApplicationDependencies.getJobManager().addListener(
|
||||
{ job -> job.factoryKey == RecipientChangedNumberJob.KEY },
|
||||
{ _, _ -> numberChangeWasEnqueued = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 PNI_A = PNI.from(UUID.fromString("154b8d92-c960-4f6c-8385-671ad2ffb999"))
|
||||
val PNI_B = PNI.from(UUID.fromString("ba92b1fb-cd55-40bf-adda-c35a85375533"))
|
||||
|
||||
const val E164_A = "+12221234567"
|
||||
const val E164_B = "+13331234567"
|
||||
}
|
||||
}
|
||||
@@ -12,17 +12,22 @@ import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
|
||||
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.push.ServiceId
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientDatabaseTest_processPnpTuple {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
private lateinit var smsDatabase: SmsDatabase
|
||||
private lateinit var threadDatabase: ThreadDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
@@ -30,6 +35,8 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
@Before
|
||||
fun setup() {
|
||||
recipientDatabase = SignalDatabase.recipients
|
||||
smsDatabase = SignalDatabase.sms
|
||||
threadDatabase = SignalDatabase.threads
|
||||
|
||||
ensureDbEmpty()
|
||||
|
||||
@@ -61,14 +68,7 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun noMatch_pniOnly() {
|
||||
test {
|
||||
process(null, PNI_A, null)
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun noMatch_noData() {
|
||||
test {
|
||||
process(null, null, null)
|
||||
@@ -188,11 +188,12 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
fun onlyPniMatches_noExistingPniSession_changeNumber() {
|
||||
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
|
||||
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
|
||||
// TODO Verify change number
|
||||
test {
|
||||
given(E164_B, PNI_A, null)
|
||||
given(E164_B, PNI_A, null, createThread = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,21 +201,23 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
fun pniAndAciMatches_changeNumber() {
|
||||
// This test, I could go either way. We decide to change the E164 on the existing row rather than create a new one.
|
||||
// But it's an "unstable E164->PNI mapping" case, which we don't expect, so as long as there's a user-visible impact that should be fine.
|
||||
// TODO Verify change number
|
||||
test {
|
||||
given(E164_B, PNI_A, ACI_A)
|
||||
given(E164_B, PNI_A, ACI_A, createThread = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches_changeNumber() {
|
||||
// TODO Verify change number
|
||||
test {
|
||||
given(E164_B, null, ACI_A)
|
||||
given(E164_B, null, ACI_A, createThread = true)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,29 +329,31 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
|
||||
// TODO Verify change number
|
||||
test {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(E164_B, PNI_B, ACI_A)
|
||||
given(E164_B, PNI_B, ACI_A, createThread = true)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164Aci_changeNumber() {
|
||||
// TODO Verify change number
|
||||
test {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(E164_B, null, ACI_A)
|
||||
given(E164_B, null, ACI_A, createThread = true)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,13 +415,27 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
private inner class TestCase {
|
||||
private val generatedIds: LinkedHashSet<RecipientId> = LinkedHashSet()
|
||||
private var expectCount = 0
|
||||
private lateinit var outputRecipientId: RecipientId
|
||||
|
||||
fun given(e164: String?, pni: PNI?, aci: ACI?) {
|
||||
generatedIds += insert(e164, pni, aci)
|
||||
fun given(e164: String?, pni: PNI?, aci: ACI?, createThread: Boolean = false) {
|
||||
val id = insert(e164, pni, aci)
|
||||
generatedIds += id
|
||||
if (createThread) {
|
||||
// Create a thread and throw a dummy message in it so it doesn't get automatically deleted
|
||||
threadDatabase.getOrCreateThreadIdFor(Recipient.resolved(id))
|
||||
smsDatabase.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
|
||||
}
|
||||
}
|
||||
|
||||
fun process(e164: String?, pni: PNI?, aci: ACI?) {
|
||||
generatedIds += recipientDatabase.processPnpTuple(e164, pni, aci, false)
|
||||
SignalDatabase.rawDatabase.beginTransaction()
|
||||
try {
|
||||
outputRecipientId = recipientDatabase.processPnpTuple(e164, pni, aci, pniVerified = false).finalId
|
||||
generatedIds += outputRecipientId
|
||||
SignalDatabase.rawDatabase.setTransactionSuccessful()
|
||||
} finally {
|
||||
SignalDatabase.rawDatabase.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
fun expect(e164: String?, pni: PNI?, aci: ACI?) {
|
||||
@@ -437,6 +456,10 @@ class RecipientDatabaseTest_processPnpTuple {
|
||||
fun expectDeleted(id: RecipientId) {
|
||||
assertNull(get(id))
|
||||
}
|
||||
|
||||
fun expectChangeNumberEvent() {
|
||||
assertEquals(1, smsDatabase.getChangeNumberMessageCount(outputRecipientId))
|
||||
}
|
||||
}
|
||||
|
||||
private data class IdRecord(
|
||||
|
||||
@@ -14,7 +14,6 @@ import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.lang.AssertionError
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.lang.IllegalStateException
|
||||
import java.util.UUID
|
||||
|
||||
@@ -68,12 +67,7 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet {
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun noMatch_pniOnly() {
|
||||
db.processPnpTupleToChangeSet(null, PNI_A, null, pniVerified = false)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun noMatch_noData() {
|
||||
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
|
||||
}
|
||||
@@ -603,11 +597,48 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet {
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.Update(
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.secondId,
|
||||
pni = PNI_A,
|
||||
),
|
||||
PnpOperation.SetE164(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_B, PNI_A, null),
|
||||
Input(E164_C, null, ACI_A),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.secondId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.secondId,
|
||||
pni = PNI_A,
|
||||
aci = ACI_A
|
||||
),
|
||||
PnpOperation.SetE164(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_C,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -806,5 +837,6 @@ class RecipientDatabaseTest_processPnpTupleToChangeSet {
|
||||
|
||||
const val E164_A = "+12221234567"
|
||||
const val E164_B = "+13331234567"
|
||||
const val E164_C = "+14441234567"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class ThreadDatabaseTest_pinned {
|
||||
SignalDatabase.mms.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val pinned = SignalDatabase.threads.pinnedThreadIds
|
||||
val pinned = SignalDatabase.threads.getPinnedThreadIds()
|
||||
assertTrue(threadId in pinned)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class ThreadDatabaseTest_pinned {
|
||||
SignalDatabase.mms.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val unarchivedCount = SignalDatabase.threads.unarchivedConversationListCount
|
||||
val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount()
|
||||
assertEquals(1, unarchivedCount)
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ class MyStoryMigrationTest {
|
||||
}
|
||||
|
||||
private fun runMigration() {
|
||||
MyStoryMigration.migrate(
|
||||
V151_MyStoryMigration.migrate(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
|
||||
SignalDatabase.rawDatabase,
|
||||
0,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package org.thoughtcrime.securesms.dependencies
|
||||
|
||||
import android.app.Application
|
||||
import okhttp3.ConnectionSpec
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.thoughtcrime.securesms.BuildConfig
|
||||
import org.thoughtcrime.securesms.KbsEnclave
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess
|
||||
import org.thoughtcrime.securesms.push.SignalServiceTrustStore
|
||||
import org.thoughtcrime.securesms.testing.Verb
|
||||
import org.thoughtcrime.securesms.testing.runSync
|
||||
import org.thoughtcrime.securesms.util.Base64
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager
|
||||
import org.whispersystems.signalservice.api.push.TrustStore
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalContactDiscoveryUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalKeyBackupServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl
|
||||
import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl
|
||||
import java.security.KeyStore
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Dependency provider used for instrumentation tests (aka androidTests).
|
||||
*
|
||||
* Handles setting up a mock web server for API calls, and provides mockable versions of [SignalServiceNetworkAccess] and
|
||||
* [KeyBackupService].
|
||||
*/
|
||||
class InstrumentationApplicationDependencyProvider(application: Application, default: ApplicationDependencyProvider) : ApplicationDependencies.Provider by default {
|
||||
|
||||
private val serviceTrustStore: TrustStore
|
||||
private val uncensoredConfiguration: SignalServiceConfiguration
|
||||
private val serviceNetworkAccessMock: SignalServiceNetworkAccess
|
||||
private val keyBackupService: KeyBackupService
|
||||
|
||||
init {
|
||||
runSync {
|
||||
webServer = MockWebServer()
|
||||
baseUrl = webServer.url("").toString()
|
||||
}
|
||||
|
||||
webServer.setDispatcher(object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val handler = handlers.firstOrNull {
|
||||
request.method == it.verb && request.path.startsWith("/${it.path}")
|
||||
}
|
||||
return handler?.responseFactory?.invoke(request) ?: MockResponse().setResponseCode(500)
|
||||
}
|
||||
})
|
||||
|
||||
serviceTrustStore = SignalServiceTrustStore(application)
|
||||
uncensoredConfiguration = SignalServiceConfiguration(
|
||||
arrayOf(SignalServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
mapOf(
|
||||
0 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
2 to arrayOf(SignalCdnUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT))
|
||||
),
|
||||
arrayOf(SignalContactDiscoveryUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalKeyBackupServiceUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalStorageUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
arrayOf(SignalCdsiUrl(baseUrl, "localhost", serviceTrustStore, ConnectionSpec.CLEARTEXT)),
|
||||
emptyList(),
|
||||
Optional.of(SignalServiceNetworkAccess.DNS),
|
||||
Optional.empty(),
|
||||
Base64.decode(BuildConfig.ZKGROUP_SERVER_PUBLIC_PARAMS)
|
||||
)
|
||||
|
||||
serviceNetworkAccessMock = mock {
|
||||
on { getConfiguration() } doReturn uncensoredConfiguration
|
||||
on { getConfiguration(any()) } doReturn uncensoredConfiguration
|
||||
on { uncensoredConfiguration } doReturn uncensoredConfiguration
|
||||
}
|
||||
|
||||
keyBackupService = mock()
|
||||
}
|
||||
|
||||
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
|
||||
return serviceNetworkAccessMock
|
||||
}
|
||||
|
||||
override fun provideKeyBackupService(signalServiceAccountManager: SignalServiceAccountManager, keyStore: KeyStore, enclave: KbsEnclave): KeyBackupService {
|
||||
return keyBackupService
|
||||
}
|
||||
|
||||
companion object {
|
||||
lateinit var webServer: MockWebServer
|
||||
private set
|
||||
lateinit var baseUrl: String
|
||||
private set
|
||||
|
||||
private val handlers: MutableList<Verb> = mutableListOf()
|
||||
|
||||
fun addMockWebRequestHandlers(vararg verbs: Verb) {
|
||||
handlers.addAll(verbs)
|
||||
}
|
||||
|
||||
fun clearHandlers() {
|
||||
handlers.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.testing.Get
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIs
|
||||
import org.thoughtcrime.securesms.testing.assertIsNot
|
||||
import org.thoughtcrime.securesms.testing.parsedRequestBody
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyState
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyStatus
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PreKeysSyncJobTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
private val aciPreKeyMeta: PreKeyMetadataStore
|
||||
get() = SignalStore.account().aciPreKeys
|
||||
|
||||
private val pniPreKeyMeta: PreKeyMetadataStore
|
||||
get() = SignalStore.account().pniPreKeys
|
||||
|
||||
private lateinit var job: PreKeysSyncJob
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
job = PreKeysSyncJob()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create signed prekeys for both identities when both do not have registered prekeys according
|
||||
* to our local state.
|
||||
*/
|
||||
@Test
|
||||
fun runWithoutRegisteredKeysForBothIdentities() {
|
||||
// GIVEN
|
||||
aciPreKeyMeta.isSignedPreKeyRegistered = false
|
||||
pniPreKeyMeta.isSignedPreKeyRegistered = false
|
||||
|
||||
lateinit var aciSignedPreKey: SignedPreKeyEntity
|
||||
lateinit var pniSignedPreKey: SignedPreKeyEntity
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v2/keys/signed?identity=aci") { r ->
|
||||
aciSignedPreKey = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Put("/v2/keys/signed?identity=pni") { r ->
|
||||
pniSignedPreKey = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result: Job.Result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
|
||||
aciPreKeyMeta.isSignedPreKeyRegistered assertIs true
|
||||
pniPreKeyMeta.isSignedPreKeyRegistered assertIs true
|
||||
|
||||
val aciVerifySignatureResult = Curve.verifySignature(
|
||||
ApplicationDependencies.getProtocolStore().aci().identityKeyPair.publicKey.publicKey,
|
||||
aciSignedPreKey.publicKey.serialize(),
|
||||
aciSignedPreKey.signature
|
||||
)
|
||||
aciVerifySignatureResult assertIs true
|
||||
|
||||
val pniVerifySignatureResult = Curve.verifySignature(
|
||||
ApplicationDependencies.getProtocolStore().pni().identityKeyPair.publicKey.publicKey,
|
||||
pniSignedPreKey.publicKey.serialize(),
|
||||
pniSignedPreKey.signature
|
||||
)
|
||||
pniVerifySignatureResult assertIs true
|
||||
}
|
||||
|
||||
/**
|
||||
* With 100 prekeys registered for each identity, do nothing.
|
||||
*/
|
||||
@Test
|
||||
fun runWithRegisteredKeysForBothIdentities() {
|
||||
// GIVEN
|
||||
val currentAciKeyId = aciPreKeyMeta.activeSignedPreKeyId
|
||||
val currentPniKeyId = pniPreKeyMeta.activeSignedPreKeyId
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
|
||||
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(100)) },
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result: Job.Result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
|
||||
aciPreKeyMeta.activeSignedPreKeyId assertIs currentAciKeyId
|
||||
pniPreKeyMeta.activeSignedPreKeyId assertIs currentPniKeyId
|
||||
}
|
||||
|
||||
/**
|
||||
* With 100 prekeys registered for ACI, but no PNI prekeys registered according to local state,
|
||||
* do nothing for ACI but create PNI prekeys and update local state.
|
||||
*/
|
||||
@Test
|
||||
fun runWithRegisteredKeysForAciIdentityOnly() {
|
||||
// GIVEN
|
||||
pniPreKeyMeta.isSignedPreKeyRegistered = false
|
||||
|
||||
val currentAciKeyId = aciPreKeyMeta.activeSignedPreKeyId
|
||||
val currentPniKeyId = pniPreKeyMeta.activeSignedPreKeyId
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(100)) },
|
||||
Put("/v2/keys/signed?identity=pni") { MockResponse().success() },
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result: Job.Result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
|
||||
pniPreKeyMeta.isSignedPreKeyRegistered assertIs true
|
||||
aciPreKeyMeta.activeSignedPreKeyId assertIs currentAciKeyId
|
||||
pniPreKeyMeta.activeSignedPreKeyId assertIsNot currentPniKeyId
|
||||
}
|
||||
|
||||
/**
|
||||
* With <10 prekeys registered for each identity, upload new.
|
||||
*/
|
||||
@Test
|
||||
fun runWithLowNumberOfRegisteredKeysForBothIdentities() {
|
||||
// GIVEN
|
||||
val currentAciKeyId = aciPreKeyMeta.activeSignedPreKeyId
|
||||
val currentPniKeyId = pniPreKeyMeta.activeSignedPreKeyId
|
||||
|
||||
val currentNextAciPreKeyId = aciPreKeyMeta.nextOneTimePreKeyId
|
||||
val currentNextPniPreKeyId = pniPreKeyMeta.nextOneTimePreKeyId
|
||||
|
||||
lateinit var aciPreKeyStateRequest: PreKeyState
|
||||
lateinit var pniPreKeyStateRequest: PreKeyState
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Get("/v2/keys?identity=aci") { MockResponse().success(PreKeyStatus(5)) },
|
||||
Get("/v2/keys?identity=pni") { MockResponse().success(PreKeyStatus(5)) },
|
||||
Put("/v2/keys/?identity=aci") { r ->
|
||||
aciPreKeyStateRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
Put("/v2/keys/?identity=pni") { r ->
|
||||
pniPreKeyStateRequest = r.parsedRequestBody()
|
||||
MockResponse().success()
|
||||
},
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result: Job.Result = job.run()
|
||||
|
||||
// THEN
|
||||
result.isSuccess assertIs true
|
||||
aciPreKeyMeta.activeSignedPreKeyId assertIsNot currentAciKeyId
|
||||
pniPreKeyMeta.activeSignedPreKeyId assertIsNot currentPniKeyId
|
||||
|
||||
aciPreKeyMeta.nextOneTimePreKeyId assertIsNot currentNextAciPreKeyId
|
||||
pniPreKeyMeta.nextOneTimePreKeyId assertIsNot currentNextPniPreKeyId
|
||||
|
||||
ApplicationDependencies.getProtocolStore().aci().identityKeyPair.publicKey.let { aciIdentityKey ->
|
||||
aciPreKeyStateRequest.identityKey assertIs aciIdentityKey
|
||||
|
||||
val verifySignatureResult = Curve.verifySignature(
|
||||
aciIdentityKey.publicKey,
|
||||
aciPreKeyStateRequest.signedPreKey.publicKey.serialize(),
|
||||
aciPreKeyStateRequest.signedPreKey.signature
|
||||
)
|
||||
verifySignatureResult assertIs true
|
||||
}
|
||||
|
||||
ApplicationDependencies.getProtocolStore().pni().identityKeyPair.publicKey.let { pniIdentityKey ->
|
||||
pniPreKeyStateRequest.identityKey assertIs pniIdentityKey
|
||||
|
||||
val verifySignatureResult = Curve.verifySignature(
|
||||
pniIdentityKey.publicKey,
|
||||
pniPreKeyStateRequest.signedPreKey.publicKey.serialize(),
|
||||
pniPreKeyStateRequest.signedPreKey.signature
|
||||
)
|
||||
verifySignatureResult assertIs true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import org.junit.Rule
|
||||
import org.thoughtcrime.securesms.messages.MessageContentProcessor.ExceptionMetadata
|
||||
import org.thoughtcrime.securesms.messages.MessageContentProcessor.MessageState
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.TestProtos
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
|
||||
abstract class MessageContentProcessorTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule()
|
||||
|
||||
protected fun MessageContentProcessor.doProcess(
|
||||
messageState: MessageState = MessageState.DECRYPTED_OK,
|
||||
content: SignalServiceContent,
|
||||
exceptionMetadata: ExceptionMetadata = ExceptionMetadata("sender", 1),
|
||||
timestamp: Long = 100L,
|
||||
smsMessageId: Long = -1L
|
||||
) {
|
||||
process(messageState, content, exceptionMetadata, timestamp, smsMessageId)
|
||||
}
|
||||
|
||||
protected fun createNormalContentTestSubject(): MessageContentProcessor {
|
||||
val context = ApplicationProvider.getApplicationContext<Application>()
|
||||
|
||||
return MessageContentProcessor.forNormalContent(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid ServiceContentProto with a data message which can be built via
|
||||
* `injectDataMessage`. This function is intended to be built on-top of for more
|
||||
* specific scenario in subclasses.
|
||||
*
|
||||
* Example can be seen in __handleStoryMessageTest
|
||||
*/
|
||||
protected fun createServiceContentWithDataMessage(
|
||||
messageSender: Recipient = Recipient.resolved(harness.others.first()),
|
||||
injectDataMessage: SignalServiceProtos.DataMessage.Builder.() -> Unit
|
||||
): SignalServiceContentProto {
|
||||
return TestProtos.build {
|
||||
serviceContent(
|
||||
localAddress = address(uuid = harness.self.requireServiceId().uuid()).build(),
|
||||
metadata = metadata(
|
||||
address = address(uuid = messageSender.requireServiceId().uuid()).build()
|
||||
).build()
|
||||
).apply {
|
||||
content = content().apply {
|
||||
dataMessage = dataMessage().apply {
|
||||
injectDataMessage()
|
||||
}.build()
|
||||
}.build()
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.storageservice.protos.groups.Member
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
||||
import org.thoughtcrime.securesms.database.MessageDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsHelper
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.TestProtos
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
import kotlin.random.Random
|
||||
|
||||
@Suppress("ClassName")
|
||||
class MessageContentProcessor__handleStoryMessageTest : MessageContentProcessorTest() {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.mms.deleteAllThreads()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
SignalDatabase.mms.deleteAllThreads()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenContentWithADirectStoryReplyWhenIProcessThenIInsertAReplyInTheCorrectThread() {
|
||||
val sender = Recipient.resolved(harness.others.first())
|
||||
val senderThreadId = SignalDatabase.threads.getOrCreateThreadIdFor(sender)
|
||||
val myStory = Recipient.resolved(SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!!)
|
||||
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
val expectedSentTime = 200L
|
||||
val storyMessageId = MmsHelper.insert(
|
||||
sentTimeMillis = expectedSentTime,
|
||||
recipient = myStory,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = myStoryThread
|
||||
)
|
||||
|
||||
SignalDatabase.storySends.insert(
|
||||
messageId = storyMessageId,
|
||||
recipientIds = listOf(sender.id),
|
||||
sentTimestamp = expectedSentTime,
|
||||
allowsReplies = true,
|
||||
distributionId = DistributionId.MY_STORY
|
||||
)
|
||||
|
||||
val expectedBody = "Hello!"
|
||||
|
||||
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
|
||||
messageSender = sender,
|
||||
storyAuthor = harness.self,
|
||||
storySentTimestamp = expectedSentTime
|
||||
) {
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
runTestWithContent(contentProto = storyContent)
|
||||
|
||||
val replyId = SignalDatabase.mmsSms.getConversation(senderThreadId, 0, 1).use {
|
||||
it.moveToFirst()
|
||||
it.requireLong(MessageDatabase.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.mms.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.DirectReply(storyMessageId).serialize(), replyRecord.parentStoryId!!.serialize())
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.mms.deleteAllThreads()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenContentWithAGroupStoryReplyWhenIProcessThenIInsertAReplyToTheCorrectStory() {
|
||||
val sender = Recipient.resolved(harness.others[0])
|
||||
val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE))
|
||||
val decryptedGroupState = DecryptedGroup.newBuilder()
|
||||
.addAllMembers(
|
||||
listOf(
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(harness.self.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
DecryptedMember.newBuilder()
|
||||
.setUuid(sender.requireServiceId().toByteString())
|
||||
.setJoinedAtRevision(0)
|
||||
.setRole(Member.Role.DEFAULT)
|
||||
.build()
|
||||
)
|
||||
)
|
||||
.setRevision(0)
|
||||
.build()
|
||||
|
||||
val group = SignalDatabase.groups.create(
|
||||
groupMasterKey,
|
||||
decryptedGroupState
|
||||
)
|
||||
|
||||
val groupRecipient = Recipient.externalGroupExact(group)
|
||||
val threadForGroup = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
|
||||
|
||||
val insertResult = MmsHelper.insert(
|
||||
message = IncomingMediaMessage(
|
||||
from = sender.id,
|
||||
sentTimeMillis = 100L,
|
||||
serverTimeMillis = 101L,
|
||||
receivedTimeMillis = 102L,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
threadId = threadForGroup
|
||||
)
|
||||
|
||||
val expectedBody = "Hello, World!"
|
||||
val storyContent: SignalServiceContentProto = createServiceContentWithStoryContext(
|
||||
messageSender = sender,
|
||||
storyAuthor = sender,
|
||||
storySentTimestamp = 100L
|
||||
) {
|
||||
groupV2 = TestProtos.build { groupContextV2(masterKeyBytes = groupMasterKey.serialize()).build() }
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
runTestWithContent(storyContent)
|
||||
|
||||
val replyId = SignalDatabase.mms.getStoryReplies(insertResult.get().messageId).use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
cursor.moveToFirst()
|
||||
cursor.requireLong(MessageDatabase.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.mms.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.GroupReply(insertResult.get().messageId).serialize(), replyRecord.parentStoryId?.serialize())
|
||||
assertEquals(threadForGroup, replyRecord.threadId)
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.mms.deleteGroupStoryReplies(insertResult.get().messageId)
|
||||
SignalDatabase.mms.deleteAllThreads()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ServiceContent proto with a StoryContext, and then
|
||||
* uses `injectDataMessage` to fill in the data message object.
|
||||
*/
|
||||
private fun createServiceContentWithStoryContext(
|
||||
messageSender: Recipient,
|
||||
storyAuthor: Recipient,
|
||||
storySentTimestamp: Long,
|
||||
injectDataMessage: DataMessage.Builder.() -> Unit
|
||||
): SignalServiceContentProto {
|
||||
return createServiceContentWithDataMessage(messageSender) {
|
||||
storyContext = TestProtos.build {
|
||||
storyContext(
|
||||
sentTimestamp = storySentTimestamp,
|
||||
authorUuid = storyAuthor.requireServiceId().toString()
|
||||
).build()
|
||||
}
|
||||
injectDataMessage()
|
||||
}
|
||||
}
|
||||
|
||||
private fun runTestWithContent(contentProto: SignalServiceContentProto) {
|
||||
val content = SignalServiceContent.createFromProto(contentProto)
|
||||
val testSubject = createNormalContentTestSubject()
|
||||
testSubject.doProcess(content = content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.messages
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceContent
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
|
||||
@Suppress("ClassName")
|
||||
class MessageContentProcessor__handleTextMessageTest : MessageContentProcessorTest() {
|
||||
@Test
|
||||
fun givenContentWithATextMessageWhenIProcessThenIInsertTheTextMessage() {
|
||||
val testSubject: MessageContentProcessor = createNormalContentTestSubject()
|
||||
val expectedBody = "Hello, World!"
|
||||
val contentProto: SignalServiceContentProto = createServiceContentWithDataMessage {
|
||||
body = expectedBody
|
||||
}
|
||||
|
||||
val content = SignalServiceContent.createFromProto(contentProto)
|
||||
|
||||
// WHEN
|
||||
testSubject.doProcess(content = content)
|
||||
|
||||
// THEN
|
||||
val record = SignalDatabase.sms.getMessageRecord(1)
|
||||
val threadSize = SignalDatabase.mmsSms.getConversationCount(record.threadId)
|
||||
assertEquals(1, threadSize)
|
||||
|
||||
assertTrue(record.isSecure)
|
||||
assertEquals(expectedBody, record.body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package org.thoughtcrime.securesms.profiles.manage
|
||||
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.testing.FragmentScenario
|
||||
import androidx.fragment.app.testing.launchFragmentInContainer
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.test.espresso.Espresso.onView
|
||||
import androidx.test.espresso.action.ViewActions.click
|
||||
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
|
||||
import androidx.test.espresso.action.ViewActions.typeText
|
||||
import androidx.test.espresso.assertion.ViewAssertions.matches
|
||||
import androidx.test.espresso.matcher.ViewMatchers
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withId
|
||||
import androidx.test.espresso.matcher.ViewMatchers.withText
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Ignore
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.testing.Put
|
||||
import org.thoughtcrime.securesms.testing.RxTestSchedulerRule
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
import org.thoughtcrime.securesms.testing.assertIsNotNull
|
||||
import org.thoughtcrime.securesms.testing.assertIsNull
|
||||
import org.thoughtcrime.securesms.testing.success
|
||||
import org.whispersystems.signalservice.internal.push.ReserveUsernameResponse
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UsernameEditFragmentTest {
|
||||
|
||||
@get:Rule
|
||||
val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
private val ioScheduler = TestScheduler()
|
||||
private val computationScheduler = TestScheduler()
|
||||
|
||||
@get:Rule
|
||||
val testSchedulerRule = RxTestSchedulerRule(
|
||||
ioTestScheduler = ioScheduler,
|
||||
computationTestScheduler = computationScheduler
|
||||
)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUsernameCreationInRegistration() {
|
||||
val scenario = createScenario(true)
|
||||
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
onView(withId(R.id.toolbar)).check { view, noViewFoundException ->
|
||||
noViewFoundException.assertIsNull()
|
||||
val toolbar = view as Toolbar
|
||||
|
||||
toolbar.navigationIcon.assertIsNull()
|
||||
}
|
||||
|
||||
onView(withText(R.string.UsernameEditFragment__add_a_username)).check(matches(isDisplayed()))
|
||||
onView(withContentDescription(R.string.load_more_header__loading)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUsernameCreationOutsideOfRegistration() {
|
||||
val scenario = createScenario()
|
||||
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
onView(withId(R.id.toolbar)).check { view, noViewFoundException ->
|
||||
noViewFoundException.assertIsNull()
|
||||
val toolbar = view as Toolbar
|
||||
|
||||
toolbar.navigationIcon.assertIsNotNull()
|
||||
}
|
||||
|
||||
onView(withText(R.string.UsernameEditFragment_username)).check(matches(isDisplayed()))
|
||||
onView(withContentDescription(R.string.load_more_header__loading)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE)))
|
||||
}
|
||||
|
||||
@Ignore("Flakey espresso test.")
|
||||
@Test
|
||||
fun testNicknameUpdateHappyPath() {
|
||||
val nickname = "Spiderman"
|
||||
val discriminator = "4578"
|
||||
val username = "$nickname${UsernameState.DELIMITER}$discriminator"
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(
|
||||
Put("/v1/accounts/username/reserved") {
|
||||
MockResponse().success(ReserveUsernameResponse(username, "reservationToken"))
|
||||
},
|
||||
Put("/v1/accounts/username/confirm") {
|
||||
MockResponse().success()
|
||||
}
|
||||
)
|
||||
|
||||
val scenario = createScenario(isInRegistration = true)
|
||||
scenario.moveToState(Lifecycle.State.RESUMED)
|
||||
|
||||
onView(withId(R.id.username_text)).perform(typeText(nickname))
|
||||
|
||||
computationScheduler.advanceTimeBy(501, TimeUnit.MILLISECONDS)
|
||||
computationScheduler.triggerActions()
|
||||
|
||||
onView(withContentDescription(R.string.load_more_header__loading)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
|
||||
|
||||
ioScheduler.triggerActions()
|
||||
computationScheduler.triggerActions()
|
||||
|
||||
onView(withId(R.id.username_text)).perform(closeSoftKeyboard())
|
||||
onView(withId(R.id.username_done_button)).check(matches(isDisplayed()))
|
||||
onView(withId(R.id.username_done_button)).check(matches(isEnabled()))
|
||||
onView(withText(username)).check(matches(isDisplayed()))
|
||||
|
||||
onView(withId(R.id.username_done_button)).perform(click())
|
||||
|
||||
computationScheduler.triggerActions()
|
||||
onView(withId(R.id.username_done_button)).check(matches(isNotEnabled()))
|
||||
}
|
||||
|
||||
private fun createScenario(isInRegistration: Boolean = false): FragmentScenario<UsernameEditFragment> {
|
||||
val fragmentArgs = UsernameEditFragmentArgs.Builder().setIsInRegistration(isInRegistration).build().toBundle()
|
||||
return launchFragmentInContainer(
|
||||
fragmentArgs = fragmentArgs,
|
||||
themeResId = R.style.Signal_DayNight_NoActionBar
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.stub
|
||||
import org.signal.core.util.Hex
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.Curve
|
||||
import org.signal.libsignal.protocol.state.PreKeyRecord
|
||||
import org.signal.libsignal.protocol.util.KeyHelper
|
||||
import org.signal.libsignal.protocol.util.Medium
|
||||
import org.thoughtcrime.securesms.crypto.PreKeyUtil
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.pin.KbsRepository
|
||||
import org.thoughtcrime.securesms.pin.TokenData
|
||||
import org.thoughtcrime.securesms.test.BuildConfig
|
||||
import org.whispersystems.signalservice.api.KbsPinData
|
||||
import org.whispersystems.signalservice.api.KeyBackupService
|
||||
import org.whispersystems.signalservice.api.kbs.HashedPin
|
||||
import org.whispersystems.signalservice.api.kbs.MasterKey
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.DeviceInfo
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignedPreKeyEntity
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.contacts.entities.TokenResponse
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import org.whispersystems.signalservice.internal.push.DeviceInfoList
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyEntity
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponse
|
||||
import org.whispersystems.signalservice.internal.push.PreKeyResponseItem
|
||||
import org.whispersystems.signalservice.internal.push.PushServiceSocket
|
||||
import org.whispersystems.signalservice.internal.push.SenderCertificate
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import org.whispersystems.signalservice.internal.push.WhoAmIResponse
|
||||
import java.security.SecureRandom
|
||||
|
||||
/**
|
||||
* Warehouse of reusable test data and mock configurations.
|
||||
*/
|
||||
object MockProvider {
|
||||
|
||||
val senderCertificate = SenderCertificate().apply { certificate = ByteArray(0) }
|
||||
|
||||
val lockedFailure = PushServiceSocket.RegistrationLockFailure().apply {
|
||||
backupCredentials = AuthCredentials.create("username", "password")
|
||||
}
|
||||
|
||||
val primaryOnlyDeviceList = DeviceInfoList().apply {
|
||||
devices = listOf(
|
||||
DeviceInfo().apply {
|
||||
id = 1
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun createVerifyAccountResponse(aci: ServiceId, newPni: ServiceId): VerifyAccountResponse {
|
||||
return VerifyAccountResponse().apply {
|
||||
uuid = aci.toString()
|
||||
pni = newPni.toString()
|
||||
storageCapable = false
|
||||
}
|
||||
}
|
||||
|
||||
fun createWhoAmIResponse(aci: ServiceId, pni: ServiceId, e164: String): WhoAmIResponse {
|
||||
return WhoAmIResponse().apply {
|
||||
this.uuid = aci.toString()
|
||||
this.pni = pni.toString()
|
||||
this.number = e164
|
||||
}
|
||||
}
|
||||
|
||||
fun mockGetRegistrationLockStringFlow(kbsRepository: KbsRepository) {
|
||||
val tokenData: TokenData = mock {
|
||||
on { enclave } doReturn BuildConfig.KBS_ENCLAVE
|
||||
on { basicAuth } doReturn "basicAuth"
|
||||
on { triesRemaining } doReturn 10
|
||||
on { tokenResponse } doReturn TokenResponse()
|
||||
}
|
||||
|
||||
kbsRepository.stub {
|
||||
on { getToken(any() as String) } doReturn Single.just(ServiceResponse.forResult(tokenData, 200, ""))
|
||||
}
|
||||
|
||||
val session: KeyBackupService.RestoreSession = object : KeyBackupService.RestoreSession {
|
||||
override fun hashSalt(): ByteArray = Hex.fromStringCondensed("cba811749042b303a6a7efa5ccd160aea5e3ea243c8d2692bd13d515732f51a8")
|
||||
override fun restorePin(hashedPin: HashedPin?): KbsPinData = KbsPinData(MasterKey.createNew(SecureRandom()), null)
|
||||
}
|
||||
|
||||
val kbsService = ApplicationDependencies.getKeyBackupService(BuildConfig.KBS_ENCLAVE)
|
||||
kbsService.stub {
|
||||
on { newRegistrationSession(any(), any()) } doReturn session
|
||||
}
|
||||
}
|
||||
|
||||
fun createPreKeyResponse(identity: IdentityKeyPair = SignalStore.account().aciIdentityKey, deviceId: Int): PreKeyResponse {
|
||||
val signedPreKeyRecord = PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), identity.privateKey)
|
||||
val oneTimePreKey = PreKeyRecord(SecureRandom().nextInt(Medium.MAX_VALUE), Curve.generateKeyPair())
|
||||
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
identityKey = identity.publicKey
|
||||
devices = listOf(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
typealias ResponseFactory = (request: RecordedRequest) -> MockResponse
|
||||
|
||||
/**
|
||||
* Represent an HTTP verb for mocking web requests.
|
||||
*/
|
||||
sealed class Verb(val verb: String, val path: String, val responseFactory: ResponseFactory)
|
||||
|
||||
class Get(path: String, responseFactory: ResponseFactory) : Verb("GET", path, responseFactory)
|
||||
|
||||
class Put(path: String, responseFactory: ResponseFactory) : Verb("PUT", path, responseFactory)
|
||||
|
||||
fun MockResponse.success(response: Any? = null): MockResponse {
|
||||
return setResponseCode(200).apply {
|
||||
if (response != null) {
|
||||
setBody(JsonUtils.toJson(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MockResponse.failure(code: Int, response: Any? = null): MockResponse {
|
||||
return setResponseCode(code).apply {
|
||||
if (response != null) {
|
||||
setBody(JsonUtils.toJson(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun MockResponse.connectionFailure(): MockResponse {
|
||||
return setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
|
||||
}
|
||||
|
||||
fun MockResponse.timeout(): MockResponse {
|
||||
return setHeadersDelay(1, TimeUnit.DAYS)
|
||||
.setBodyDelay(1, TimeUnit.DAYS)
|
||||
}
|
||||
|
||||
inline fun <reified T> RecordedRequest.parsedRequestBody(): T {
|
||||
val bodyString = String(body.readByteArray())
|
||||
return JsonUtils.fromJson(bodyString, T::class.java)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.rules.ExternalResource
|
||||
|
||||
/**
|
||||
* JUnit Rule which initialises Rx thread schedulers. If a specific
|
||||
* scheduler is not specified, it defaults to the `defaultTestScheduler`
|
||||
*/
|
||||
class RxTestSchedulerRule(
|
||||
val defaultTestScheduler: TestScheduler = TestScheduler(),
|
||||
val ioTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val computationTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val singleTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
val newThreadTestScheduler: TestScheduler = defaultTestScheduler,
|
||||
) : ExternalResource() {
|
||||
|
||||
override fun before() {
|
||||
RxJavaPlugins.setInitIoSchedulerHandler { ioTestScheduler }
|
||||
RxJavaPlugins.setIoSchedulerHandler { ioTestScheduler }
|
||||
|
||||
RxJavaPlugins.setInitComputationSchedulerHandler { computationTestScheduler }
|
||||
RxJavaPlugins.setComputationSchedulerHandler { computationTestScheduler }
|
||||
|
||||
RxJavaPlugins.setInitSingleSchedulerHandler { singleTestScheduler }
|
||||
RxJavaPlugins.setSingleSchedulerHandler { singleTestScheduler }
|
||||
|
||||
RxJavaPlugins.setInitNewThreadSchedulerHandler { newThreadTestScheduler }
|
||||
RxJavaPlugins.setNewThreadSchedulerHandler { newThreadTestScheduler }
|
||||
}
|
||||
|
||||
override fun after() {
|
||||
RxJavaPlugins.reset()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import android.content.SharedPreferences
|
||||
import android.preference.PreferenceManager
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.rules.ExternalResource
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
@@ -17,6 +18,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.dependencies.InstrumentationApplicationDependencyProvider
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
@@ -29,6 +31,8 @@ import org.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import org.whispersystems.signalservice.internal.ServiceResponseProcessor
|
||||
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
|
||||
import java.lang.IllegalArgumentException
|
||||
import java.util.UUID
|
||||
@@ -54,6 +58,8 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
self = setupSelf()
|
||||
others = setupOthers()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
private fun setupSelf(): Recipient {
|
||||
@@ -67,18 +73,22 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
|
||||
registrationRepository.registerAccountWithoutRegistrationLock(
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
|
||||
val response: ServiceResponse<VerifyAccountResponse> = registrationRepository.registerAccountWithoutRegistrationLock(
|
||||
RegistrationData(
|
||||
code = "123123",
|
||||
e164 = "+15554045550101",
|
||||
e164 = "+15555550101",
|
||||
password = Util.getSecret(18),
|
||||
registrationId = registrationRepository.registrationId,
|
||||
profileKey = registrationRepository.getProfileKey("+15554045550101"),
|
||||
fcmToken = null
|
||||
profileKey = registrationRepository.getProfileKey("+15555550101"),
|
||||
fcmToken = null,
|
||||
pniRegistrationId = registrationRepository.pniRegistrationId
|
||||
),
|
||||
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false)
|
||||
).blockingGet()
|
||||
|
||||
ServiceResponseProcessor.DefaultProcessor(response).resultOrThrow
|
||||
|
||||
SignalStore.kbsValues().optOut()
|
||||
RegistrationUtil.maybeMarkRegistrationComplete(application)
|
||||
SignalDatabase.recipients.setProfileName(Recipient.self().id, ProfileName.fromParts("Tester", "McTesterson"))
|
||||
@@ -98,8 +108,9 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i)))
|
||||
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true))
|
||||
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true, true))
|
||||
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||
SignalDatabase.recipients.markRegistered(recipientId, aci)
|
||||
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(aci.toString(), 0), IdentityKeyUtil.generateIdentityKeyPair().publicKey)
|
||||
others += recipientId
|
||||
}
|
||||
@@ -107,7 +118,7 @@ class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource()
|
||||
return others
|
||||
}
|
||||
|
||||
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit): ActivityScenario<T> {
|
||||
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit = {}): ActivityScenario<T> {
|
||||
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import org.thoughtcrime.securesms.SignalInstrumentationApplicationContext
|
||||
|
||||
/**
|
||||
* Custom runner that replaces application with [SignalInstrumentationApplicationContext].
|
||||
*/
|
||||
@Suppress("unused")
|
||||
class SignalTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, SignalInstrumentationApplicationContext::class.java.name, context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.AddressProto
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.MetadataProto
|
||||
import org.whispersystems.signalservice.internal.serialize.protos.SignalServiceContentProto
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class TestProtos private constructor() {
|
||||
fun address(
|
||||
uuid: UUID = UUID.randomUUID()
|
||||
): AddressProto.Builder {
|
||||
return AddressProto.newBuilder()
|
||||
.setUuid(ServiceId.from(uuid).toByteString())
|
||||
}
|
||||
|
||||
fun metadata(
|
||||
address: AddressProto = address().build(),
|
||||
): MetadataProto.Builder {
|
||||
return MetadataProto.newBuilder()
|
||||
.setAddress(address)
|
||||
}
|
||||
|
||||
fun groupContextV2(
|
||||
revision: Int = 0,
|
||||
masterKeyBytes: ByteArray = Random.Default.nextBytes(GroupMasterKey.SIZE)
|
||||
): GroupContextV2.Builder {
|
||||
return GroupContextV2.newBuilder()
|
||||
.setRevision(revision)
|
||||
.setMasterKey(ByteString.copyFrom(masterKeyBytes))
|
||||
}
|
||||
|
||||
fun storyContext(
|
||||
sentTimestamp: Long = Random.nextLong(),
|
||||
authorUuid: String = UUID.randomUUID().toString()
|
||||
): DataMessage.StoryContext.Builder {
|
||||
return DataMessage.StoryContext.newBuilder()
|
||||
.setAuthorUuid(authorUuid)
|
||||
.setSentTimestamp(sentTimestamp)
|
||||
}
|
||||
|
||||
fun dataMessage(): DataMessage.Builder {
|
||||
return DataMessage.newBuilder()
|
||||
}
|
||||
|
||||
fun content(): SignalServiceProtos.Content.Builder {
|
||||
return SignalServiceProtos.Content.newBuilder()
|
||||
}
|
||||
|
||||
fun serviceContent(
|
||||
localAddress: AddressProto = address().build(),
|
||||
metadata: MetadataProto = metadata().build()
|
||||
): SignalServiceContentProto.Builder {
|
||||
return SignalServiceContentProto.newBuilder()
|
||||
.setLocalAddress(localAddress)
|
||||
.setMetadata(metadata)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun <T> build(buildFn: TestProtos.() -> T): T {
|
||||
return TestProtos().buildFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.hamcrest.MatcherAssert.assertThat
|
||||
import org.hamcrest.Matchers.hasSize
|
||||
import org.hamcrest.Matchers.`is`
|
||||
import org.hamcrest.Matchers.not
|
||||
import org.hamcrest.Matchers.notNullValue
|
||||
import org.hamcrest.Matchers.nullValue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
|
||||
/**
|
||||
* Run the given [runnable] on a new thread and wait for it to finish.
|
||||
*/
|
||||
fun runSync(runnable: () -> Unit) {
|
||||
val lock = CountDownLatch(1)
|
||||
Thread {
|
||||
try {
|
||||
runnable.invoke()
|
||||
} finally {
|
||||
lock.countDown()
|
||||
}
|
||||
}.start()
|
||||
lock.await()
|
||||
}
|
||||
|
||||
/* Various kotlin-ifications of hamcrest matchers */
|
||||
|
||||
fun <T : Any?> T.assertIsNull() {
|
||||
assertThat(this, nullValue())
|
||||
}
|
||||
|
||||
fun <T : Any?> T.assertIsNotNull() {
|
||||
assertThat(this, notNullValue())
|
||||
}
|
||||
|
||||
infix fun <T : Any> T.assertIs(expected: T) {
|
||||
assertThat(this, `is`(expected))
|
||||
}
|
||||
|
||||
infix fun <T : Any> T.assertIsNot(expected: T) {
|
||||
assertThat(this, not(`is`(expected)))
|
||||
}
|
||||
|
||||
infix fun <E, T : Collection<E>> T.assertIsSize(expected: Int) {
|
||||
assertThat(this, hasSize(expected))
|
||||
}
|
||||
11
app/src/instrumentation/AndroidManifest.xml
Normal file
11
app/src/instrumentation/AndroidManifest.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="org.thoughtcrime.securesms">
|
||||
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:replace="android:usesCleartextTraffic"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
</manifest>
|
||||
@@ -22,7 +22,6 @@
|
||||
<uses-permission android:name="android.permission.USE_FINGERPRINT"/>
|
||||
<uses-permission android:name="org.thoughtcrime.securesms.ACCESS_SECRETS"/>
|
||||
<uses-permission android:name="android.permission.READ_PROFILE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_PROFILE"/>
|
||||
<uses-permission android:name="android.permission.BROADCAST_WAP_PUSH"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
@@ -93,6 +92,8 @@
|
||||
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
|
||||
|
||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||
|
||||
<application android:name=".ApplicationContext"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
@@ -109,7 +110,7 @@
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
||||
android:value="${mapsKey}"/>
|
||||
|
||||
<meta-data android:name="android.supports_size_changes"
|
||||
android:value="true" />
|
||||
@@ -155,7 +156,8 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".DeviceProvisioningActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -181,10 +183,10 @@
|
||||
|
||||
<activity android:name=".sharing.v2.ShareActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="true"
|
||||
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" />
|
||||
@@ -212,13 +214,14 @@
|
||||
</activity>
|
||||
|
||||
<activity android:name=".stickers.StickerPackPreviewActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:noHistory="true"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.VIEW" android:exported="true" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="sgnl"
|
||||
@@ -255,6 +258,7 @@
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Signal.Transparent">
|
||||
|
||||
@@ -386,10 +390,12 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".verify.VerifyIdentityActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".components.settings.app.AppSettingsActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
@@ -489,10 +495,12 @@
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".MediaPreviewActivity"
|
||||
<activity android:name=".mediapreview.MediaPreviewV2Activity"
|
||||
android:label="@string/AndroidManifest__media_preview"
|
||||
android:windowSoftInputMode="stateHidden"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:exported="false"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".AvatarPreviewActivity"
|
||||
@@ -517,10 +525,11 @@
|
||||
android:finishOnTaskLaunch="true" />
|
||||
|
||||
<activity android:name=".PlayServicesProblemActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/TextSecure.DialogActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".SmsSendtoActivity">
|
||||
<activity android:name=".SmsSendtoActivity" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SENDTO" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
@@ -539,6 +548,7 @@
|
||||
</activity>
|
||||
|
||||
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:permission="android.permission.CALL_PHONE"
|
||||
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
||||
@@ -554,7 +564,7 @@
|
||||
</activity>
|
||||
|
||||
<activity android:name=".mediasend.AvatarSelectionActivity"
|
||||
android:theme="@style/TextSecure.FullScreenMedia"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".blocked.BlockedUsersActivity"
|
||||
@@ -570,6 +580,10 @@
|
||||
android:theme="@style/TextSecure.LightRegistrationTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
|
||||
<activity android:name=".profiles.username.AddAUsernameActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
|
||||
<activity android:name=".profiles.manage.ManageProfileActivity"
|
||||
android:theme="@style/TextSecure.LightTheme"
|
||||
android:windowSoftInputMode="stateVisible|adjustResize" />
|
||||
@@ -653,13 +667,19 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity android:name=".megaphone.SmsExportMegaphoneActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<activity android:name=".ratelimit.RecaptchaProofActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode" />
|
||||
|
||||
<activity android:name=".wallpaper.crop.WallpaperImageSelectionActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.FullScreenMedia" />
|
||||
android:theme="@style/TextSecure.DarkNoActionBar" />
|
||||
|
||||
<activity android:name=".wallpaper.crop.WallpaperCropActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
@@ -670,6 +690,17 @@
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".exporter.flow.SmsExportActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".components.settings.app.subscription.donate.DonateToSignalActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<service android:enabled="true" android:name=".exporter.SignalSmsExportService" android:foregroundServiceType="dataSync" />
|
||||
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||
@@ -682,13 +713,13 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service android:name=".components.voice.VoiceNotePlaybackService">
|
||||
<service android:name=".components.voice.VoiceNotePlaybackService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
@@ -728,7 +759,7 @@
|
||||
|
||||
<service android:name=".gcm.FcmFetchForegroundService" />
|
||||
|
||||
<service android:name=".gcm.FcmReceiveService">
|
||||
<service android:name=".gcm.FcmReceiveService" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
@@ -819,51 +850,53 @@
|
||||
|
||||
</provider>
|
||||
|
||||
<receiver android:name=".service.BootReceiver">
|
||||
<receiver android:name=".service.BootReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="org.thoughtcrime.securesms.RESTART"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.DirectoryRefreshListener">
|
||||
<receiver android:name=".service.DirectoryRefreshListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.RotateSignedPreKeyListener">
|
||||
<receiver android:name=".service.RotateSignedPreKeyListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.RotateSenderCertificateListener">
|
||||
<receiver android:name=".service.RotateSenderCertificateListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".messageprocessingalarm.MessageProcessReceiver">
|
||||
<receiver android:name=".messageprocessingalarm.MessageProcessReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="org.thoughtcrime.securesms.action.PROCESS_MESSAGES" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.LocalBackupListener">
|
||||
<receiver android:name=".service.LocalBackupListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".service.PersistentConnectionBootListener">
|
||||
<receiver android:name="org.thoughtcrime.securesms.jobs.ForegroundUtil$Receiver" android:exported="false" />
|
||||
|
||||
<receiver android:name=".service.PersistentConnectionBootListener" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver android:name=".notifications.LocaleChangedReceiver">
|
||||
<receiver android:name=".notifications.LocaleChangedReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.LOCALE_CHANGED"/>
|
||||
</intent-filter>
|
||||
@@ -871,7 +904,7 @@
|
||||
|
||||
<receiver android:name=".notifications.MessageNotifier$ReminderReceiver"/>
|
||||
|
||||
<receiver android:name=".notifications.DeleteNotificationReceiver">
|
||||
<receiver android:name=".notifications.DeleteNotificationReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.thoughtcrime.securesms.DELETE_NOTIFICATION"/>
|
||||
</intent-filter>
|
||||
@@ -899,16 +932,19 @@
|
||||
|
||||
<service
|
||||
android:name=".jobmanager.KeepAliveService"
|
||||
android:enabled="@bool/enable_alarm_manager" />
|
||||
android:enabled="@bool/enable_alarm_manager"
|
||||
android:exported="false"/>
|
||||
|
||||
<receiver
|
||||
android:name=".jobmanager.AlarmManagerScheduler$RetryReceiver"
|
||||
android:enabled="@bool/enable_alarm_manager" />
|
||||
android:enabled="@bool/enable_alarm_manager"
|
||||
android:exported="false"/>
|
||||
|
||||
<!-- Probably don't need this one -->
|
||||
<receiver
|
||||
android:name=".jobmanager.BootReceiver"
|
||||
android:enabled="true">
|
||||
android:enabled="true"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package androidx.documentfile.provider;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
/**
|
||||
* Located in androidx package as {@link TreeDocumentFile} is package protected.
|
||||
*/
|
||||
public class DocumentFileHelper {
|
||||
|
||||
private static final String TAG = Log.tag(DocumentFileHelper.class);
|
||||
|
||||
/**
|
||||
* System implementation swallows the exception and we are having problems with the rename. This inlines the
|
||||
* same call and logs the exception. Note this implementation does not update the passed in document file like
|
||||
* the system implementation. Do not use the provided document file after calling this method.
|
||||
*
|
||||
* @return true if rename successful
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
public static boolean renameTo(Context context, DocumentFile documentFile, String displayName) {
|
||||
if (documentFile instanceof TreeDocumentFile) {
|
||||
Log.d(TAG, "Renaming document directly");
|
||||
try {
|
||||
final Uri result = DocumentsContract.renameDocument(context.getContentResolver(), documentFile.getUri(), displayName);
|
||||
return result != null;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Unable to rename document file", e);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Letting OS rename document: " + documentFile.getClass().getSimpleName());
|
||||
return documentFile.renameTo(displayName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.stories.Stories;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
@@ -14,12 +15,13 @@ public final class AppCapabilities {
|
||||
private static final boolean ANNOUNCEMENT_GROUPS = true;
|
||||
private static final boolean SENDER_KEY = true;
|
||||
private static final boolean CHANGE_NUMBER = true;
|
||||
private static final boolean STORIES = true;
|
||||
|
||||
/**
|
||||
* @param storageCapable Whether or not the user can use storage service. This is another way of
|
||||
* 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(), FeatureFlags.giftBadgeReceiveSupport());
|
||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, STORIES, FeatureFlags.giftBadgeReceiveSupport(), FeatureFlags.phoneNumberPrivacy());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.VisibleForTesting;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.multidex.MultiDexApplication;
|
||||
@@ -49,20 +50,21 @@ import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.PnpInitializeDevicesJob;
|
||||
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob;
|
||||
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.RetrieveRemoteAnnouncementsJob;
|
||||
import org.thoughtcrime.securesms.jobs.StoryOnboardingDownloadJob;
|
||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeepMessagesDuration;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||
import org.thoughtcrime.securesms.logging.PersistentLogger;
|
||||
@@ -179,13 +181,12 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||
.addNonBlocking(this::initializeFcmCheck)
|
||||
.addNonBlocking(CreateSignedPreKeyJob::enqueueIfNeeded)
|
||||
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
|
||||
.addNonBlocking(this::initializePeriodicTasks)
|
||||
.addNonBlocking(this::initializeCircumvention)
|
||||
.addNonBlocking(this::initializePendingMessages)
|
||||
.addNonBlocking(this::initializeCleanup)
|
||||
.addNonBlocking(this::initializeGlideCodecs)
|
||||
.addNonBlocking(RefreshPreKeysJob::scheduleIfNecessary)
|
||||
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
||||
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
|
||||
.addNonBlocking(EmojiSource::refresh)
|
||||
@@ -195,6 +196,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||
.addPostRender(this::initializeExpiringMessageManager)
|
||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||
.addPostRender(this::initializeTrimThreadsByDateManager)
|
||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||
@@ -205,6 +207,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -230,6 +233,16 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
KeyCachingService.onAppForegrounded(this);
|
||||
ApplicationDependencies.getShakeToReport().enable();
|
||||
checkBuildExpiration();
|
||||
|
||||
long lastForegroundTime = SignalStore.misc().getLastForegroundTime();
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long timeDiff = currentTime - lastForegroundTime;
|
||||
|
||||
if (timeDiff < 0) {
|
||||
Log.w(TAG, "Time travel! The system clock has moved backwards. (currentTime: " + currentTime + " ms, lastForegroundTime: " + lastForegroundTime + " ms, diff: " + timeDiff + " ms)");
|
||||
}
|
||||
|
||||
SignalStore.misc().setLastForegroundTime(currentTime);
|
||||
});
|
||||
|
||||
Log.d(TAG, "onStart() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -329,7 +342,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getIncomingMessageObserver();
|
||||
}
|
||||
|
||||
private void initializeAppDependencies() {
|
||||
@VisibleForTesting
|
||||
void initializeAppDependencies() {
|
||||
ApplicationDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
|
||||
@@ -373,6 +387,13 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary();
|
||||
}
|
||||
|
||||
private void initializeTrimThreadsByDateManager() {
|
||||
KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration();
|
||||
if (keepMessagesDuration != KeepMessagesDuration.FOREVER) {
|
||||
ApplicationDependencies.getTrimThreadsByDateManager().scheduleIfNecessary();
|
||||
}
|
||||
}
|
||||
|
||||
private void initializePeriodicTasks() {
|
||||
RotateSignedPreKeyListener.schedule(this);
|
||||
DirectoryRefreshListener.schedule(this);
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
|
||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||
@@ -47,7 +48,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean canPlayInline,
|
||||
@NonNull Colorizer colorizer,
|
||||
boolean isCondensedMode);
|
||||
@NonNull ConversationItemDisplayMode displayMode);
|
||||
|
||||
@NonNull ConversationMessage getConversationMessage();
|
||||
|
||||
@@ -102,6 +103,9 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onDonateClicked();
|
||||
void onBlockJoinRequest(@NonNull Recipient recipient);
|
||||
void onRecipientNameClicked(@NonNull RecipientId target);
|
||||
void onInviteToSignalClicked();
|
||||
void onActivatePaymentsClicked();
|
||||
void onSendPaymentClicked(@NonNull RecipientId recipientId);
|
||||
|
||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||
boolean onUrlClicked(@NonNull String url);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.biometric.BiometricPrompt.PromptInfo
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
/**
|
||||
* Authentication using phone biometric (face, fingerprint recognition) or device lock (pattern, pin or passphrase).
|
||||
*/
|
||||
class BiometricDeviceAuthentication(
|
||||
private val biometricManager: BiometricManager,
|
||||
private val biometricPrompt: BiometricPrompt,
|
||||
private val biometricPromptInfo: PromptInfo
|
||||
) {
|
||||
companion object {
|
||||
const val AUTHENTICATED = 1
|
||||
const val NOT_AUTHENTICATED = -1
|
||||
const val TAG: String = "BiometricDeviceAuth"
|
||||
const val BIOMETRIC_AUTHENTICATORS = BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
const val ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS or BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
}
|
||||
|
||||
fun authenticate(context: Context, force: Boolean, showConfirmDeviceCredentialIntent: () -> Unit): Boolean {
|
||||
val isKeyGuardSecure = ServiceUtil.getKeyguardManager(context).isKeyguardSecure
|
||||
|
||||
if (!isKeyGuardSecure) {
|
||||
Log.w(TAG, "Keyguard not secure...")
|
||||
return false
|
||||
}
|
||||
|
||||
return if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
if (force) {
|
||||
Log.i(TAG, "Listening for biometric authentication...")
|
||||
biometricPrompt.authenticate(biometricPromptInfo)
|
||||
} else {
|
||||
Log.i(TAG, "Skipping show system biometric or device lock dialog unless forced")
|
||||
}
|
||||
true
|
||||
} else if (Build.VERSION.SDK_INT >= 21) {
|
||||
if (force) {
|
||||
Log.i(TAG, "firing intent...")
|
||||
showConfirmDeviceCredentialIntent()
|
||||
} else {
|
||||
Log.i(TAG, "Skipping firing intent unless forced")
|
||||
}
|
||||
true
|
||||
} else {
|
||||
Log.w(TAG, "Not compatible...")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAuthentication() {
|
||||
biometricPrompt.cancelAuthentication()
|
||||
}
|
||||
}
|
||||
|
||||
class BiometricDeviceLockContract : ActivityResultContract<String, Int>() {
|
||||
|
||||
@RequiresApi(api = 21)
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val keyguardManager = ServiceUtil.getKeyguardManager(context)
|
||||
return keyguardManager.createConfirmDeviceCredentialIntent(input, "")
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?) =
|
||||
if (resultCode != Activity.RESULT_OK) {
|
||||
BiometricDeviceAuthentication.NOT_AUTHENTICATED
|
||||
} else {
|
||||
BiometricDeviceAuthentication.AUTHENTICATED
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ 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.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
@@ -69,8 +70,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
||||
@Override
|
||||
protected void onCreate(Bundle icicle, boolean ready) {
|
||||
if (!getIntent().hasExtra(ContactSelectionListFragment.DISPLAY_MODE)) {
|
||||
int displayMode = Util.isDefaultSmsProvider(this) ? DisplayMode.FLAG_ALL
|
||||
: DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
|
||||
boolean includeSms = Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
int displayMode = includeSms ? DisplayMode.FLAG_ALL : DisplayMode.FLAG_PUSH | DisplayMode.FLAG_ACTIVE_GROUPS | DisplayMode.FLAG_INACTIVE_GROUPS | DisplayMode.FLAG_SELF;
|
||||
getIntent().putExtra(ContactSelectionListFragment.DISPLAY_MODE, displayMode);
|
||||
}
|
||||
|
||||
|
||||
@@ -144,20 +144,20 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private MappingAdapter contactChipAdapter;
|
||||
private ContactChipViewModel contactChipViewModel;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
|
||||
private HeaderActionProvider headerActionProvider;
|
||||
private TextView headerActionView;
|
||||
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@Nullable private FixedViewsAdapter footerAdapter;
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
private GlideRequests glideRequests;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean hideCount;
|
||||
private boolean canSelectSelf;
|
||||
@Nullable private FixedViewsAdapter headerAdapter;
|
||||
@Nullable private FixedViewsAdapter footerAdapter;
|
||||
@Nullable private ListCallback listCallback;
|
||||
@Nullable private ScrollCallback scrollCallback;
|
||||
@Nullable private OnItemLongClickListener onItemLongClickListener;
|
||||
private GlideRequests glideRequests;
|
||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||
private Set<RecipientId> currentSelection;
|
||||
private boolean isMulti;
|
||||
private boolean hideCount;
|
||||
private boolean canSelectSelf;
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull Context context) {
|
||||
@@ -206,6 +206,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
if (getParentFragment() instanceof HeaderActionProvider) {
|
||||
headerActionProvider = (HeaderActionProvider) getParentFragment();
|
||||
}
|
||||
|
||||
if (context instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) context;
|
||||
}
|
||||
|
||||
if (getParentFragment() instanceof OnItemLongClickListener) {
|
||||
onItemLongClickListener = (OnItemLongClickListener) getParentFragment();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -720,6 +728,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onItemLongClick(ContactSelectionListItem item) {
|
||||
if (onItemLongClickListener != null) {
|
||||
return onItemLongClickListener.onLongClick(item, recyclerView);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean selectionHardLimitReached() {
|
||||
@@ -850,6 +867,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
@NonNull HeaderAction getHeaderAction();
|
||||
}
|
||||
|
||||
public interface OnItemLongClickListener {
|
||||
boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView);
|
||||
}
|
||||
|
||||
public interface AbstractContactsCursorLoaderFactoryProvider {
|
||||
@NonNull AbstractContactsCursorLoader.Factory get();
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.os.Bundle;
|
||||
import android.os.Vibrator;
|
||||
import android.text.TextUtils;
|
||||
import android.transition.TransitionInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
@@ -54,6 +56,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
private DeviceAddFragment deviceAddFragment;
|
||||
private DeviceListFragment deviceListFragment;
|
||||
private DeviceLinkFragment deviceLinkFragment;
|
||||
private MenuItem cameraSwitchItem = null;
|
||||
|
||||
@Override
|
||||
public void onPreCreate() {
|
||||
@@ -102,6 +105,18 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
getMenuInflater().inflate(R.menu.device_add, menu);
|
||||
cameraSwitchItem = menu.findItem(R.id.device_add_camera_switch);
|
||||
cameraSwitchItem.setVisible(false);
|
||||
return super.onCreateOptionsMenu(menu);
|
||||
}
|
||||
|
||||
public MenuItem getCameraSwitchItem() {
|
||||
return cameraSwitchItem;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Permissions.with(this)
|
||||
|
||||
@@ -2,23 +2,23 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
import android.animation.Animator;
|
||||
import android.annotation.TargetApi;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewAnimationUtils;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.animation.DecelerateInterpolator;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.view.ViewCompat;
|
||||
|
||||
import org.signal.qr.QrScannerView;
|
||||
import org.signal.qr.kitkat.ScanListener;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
@@ -31,12 +31,13 @@ public class DeviceAddFragment extends LoggingFragment {
|
||||
|
||||
private ImageView devicesImage;
|
||||
private ScanListener scanListener;
|
||||
private QrScannerView scannerView;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||
ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
|
||||
|
||||
QrScannerView scannerView = container.findViewById(R.id.scanner);
|
||||
this.scannerView = container.findViewById(R.id.scanner);
|
||||
this.devicesImage = container.findViewById(R.id.devices);
|
||||
ViewCompat.setTransitionName(devicesImage, "devices");
|
||||
|
||||
@@ -57,7 +58,7 @@ public class DeviceAddFragment extends LoggingFragment {
|
||||
});
|
||||
}
|
||||
|
||||
scannerView.start(getViewLifecycleOwner());
|
||||
scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted());
|
||||
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
|
||||
@@ -76,6 +77,19 @@ public class DeviceAddFragment extends LoggingFragment {
|
||||
return container;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
MenuItem switchCamera = ((DeviceActivity) requireActivity()).getCameraSwitchItem();
|
||||
|
||||
if (switchCamera != null) {
|
||||
switchCamera.setVisible(true);
|
||||
switchCamera.setOnMenuItemClickListener(v -> {
|
||||
scannerView.toggleCamera();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ImageView getDevicesImage() {
|
||||
return devicesImage;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
@@ -118,7 +119,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||
|
||||
if (Util.isDefaultSmsProvider(this)) {
|
||||
if (Util.isDefaultSmsProvider(this) && SignalStore.misc().getSmsExportPhase().isSmsSupported()) {
|
||||
shareButton.setOnClickListener(new ShareClickListener());
|
||||
smsButton.setOnClickListener(new SmsClickListener());
|
||||
} else {
|
||||
|
||||
@@ -1,893 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2014 Open 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;
|
||||
|
||||
import android.Manifest;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.AsyncTask;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.app.ShareCompat;
|
||||
import androidx.core.util.Pair;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.fragment.app.FragmentStatePagerAdapter;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
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;
|
||||
import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaPreviewViewModel;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaRailAdapter;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
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.util.AttachmentUtil;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
|
||||
import org.thoughtcrime.securesms.util.StorageUtil;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Activity for displaying media attachments in-app
|
||||
*/
|
||||
public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||
implements LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
|
||||
MediaRailAdapter.RailItemListener,
|
||||
MediaPreviewFragment.Events,
|
||||
VoiceNoteMediaControllerOwner
|
||||
{
|
||||
|
||||
private final static String TAG = Log.tag(MediaPreviewActivity.class);
|
||||
|
||||
private static final int NOT_IN_A_THREAD = -2;
|
||||
|
||||
public static final String THREAD_ID_EXTRA = "thread_id";
|
||||
public static final String DATE_EXTRA = "date";
|
||||
public static final String SIZE_EXTRA = "size";
|
||||
public static final String CAPTION_EXTRA = "caption";
|
||||
public static final String LEFT_IS_RECENT_EXTRA = "left_is_recent";
|
||||
public static final String HIDE_ALL_MEDIA_EXTRA = "came_from_all_media";
|
||||
public static final String SHOW_THREAD_EXTRA = "show_thread";
|
||||
public static final String SORTING_EXTRA = "sorting";
|
||||
public static final String IS_VIDEO_GIF = "is_video_gif";
|
||||
|
||||
private ViewPager mediaPager;
|
||||
private View detailsContainer;
|
||||
private TextView caption;
|
||||
private View captionContainer;
|
||||
private RecyclerView albumRail;
|
||||
private MediaRailAdapter albumRailAdapter;
|
||||
private ViewGroup playbackControlsContainer;
|
||||
private Uri initialMediaUri;
|
||||
private String initialMediaType;
|
||||
private long initialMediaSize;
|
||||
private String initialCaption;
|
||||
private boolean initialMediaIsVideoGif;
|
||||
private boolean leftIsRecent;
|
||||
private MediaPreviewViewModel viewModel;
|
||||
private ViewPagerListener viewPagerListener;
|
||||
|
||||
private int restartItem = -1;
|
||||
private long threadId = NOT_IN_A_THREAD;
|
||||
private boolean cameFromAllMedia;
|
||||
private boolean showThread;
|
||||
private MediaDatabase.Sorting sorting;
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
|
||||
private VoiceNoteMediaController voiceNoteMediaController;
|
||||
|
||||
private @Nullable Cursor cursor = null;
|
||||
|
||||
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
|
||||
@NonNull MediaRecord mediaRecord,
|
||||
boolean leftIsRecent)
|
||||
{
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
Intent intent = new Intent(context, MediaPreviewActivity.class);
|
||||
intent.putExtra(MediaPreviewActivity.THREAD_ID_EXTRA, mediaRecord.getThreadId());
|
||||
intent.putExtra(MediaPreviewActivity.DATE_EXTRA, mediaRecord.getDate());
|
||||
intent.putExtra(MediaPreviewActivity.SIZE_EXTRA, attachment.getSize());
|
||||
intent.putExtra(MediaPreviewActivity.CAPTION_EXTRA, attachment.getCaption());
|
||||
intent.putExtra(MediaPreviewActivity.LEFT_IS_RECENT_EXTRA, leftIsRecent);
|
||||
intent.putExtra(MediaPreviewActivity.IS_VIDEO_GIF, attachment.isVideoGif());
|
||||
intent.setDataAndType(attachment.getUri(), mediaRecord.getContentType());
|
||||
return intent;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(@NonNull Context newBase) {
|
||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||
super.attachBaseContext(newBase);
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
this.setTheme(R.style.TextSecure_MediaPreview);
|
||||
setContentView(R.layout.media_preview_activity);
|
||||
|
||||
setSupportActionBar(findViewById(R.id.toolbar));
|
||||
|
||||
voiceNoteMediaController = new VoiceNoteMediaController(this);
|
||||
viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class);
|
||||
|
||||
fullscreenHelper = new FullscreenHelper(this);
|
||||
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
initializeViews();
|
||||
initializeResources();
|
||||
initializeObservers();
|
||||
}
|
||||
|
||||
@SuppressLint("MissingSuperCall")
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRailItemClicked(int distanceFromActive) {
|
||||
mediaPager.setCurrentItem(mediaPager.getCurrentItem() + distanceFromActive);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRailItemDeleteClicked(int distanceFromActive) {
|
||||
throw new UnsupportedOperationException("Callback unsupported.");
|
||||
}
|
||||
|
||||
@SuppressWarnings("ConstantConditions")
|
||||
private void initializeActionBar() {
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
|
||||
if (mediaItem != null) {
|
||||
getSupportActionBar().setTitle(getTitleText(mediaItem));
|
||||
getSupportActionBar().setSubtitle(getSubTitleText(mediaItem));
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getTitleText(@NonNull MediaItem mediaItem) {
|
||||
String from;
|
||||
if (mediaItem.outgoing) from = getString(R.string.MediaPreviewActivity_you);
|
||||
else if (mediaItem.recipient != null) from = mediaItem.recipient.getDisplayName(this);
|
||||
else from = "";
|
||||
|
||||
if (showThread) {
|
||||
String titleText = null;
|
||||
Recipient threadRecipient = mediaItem.threadRecipient;
|
||||
|
||||
if (threadRecipient != null) {
|
||||
if (mediaItem.outgoing) {
|
||||
if (threadRecipient.isSelf()) {
|
||||
titleText = getString(R.string.note_to_self);
|
||||
} else {
|
||||
titleText = getString(R.string.MediaPreviewActivity_you_to_s, threadRecipient.getDisplayName(this));
|
||||
}
|
||||
} else {
|
||||
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 titleText != null ? titleText : from;
|
||||
} else {
|
||||
return from;
|
||||
}
|
||||
}
|
||||
|
||||
private @NonNull String getSubTitleText(@NonNull MediaItem mediaItem) {
|
||||
if (mediaItem.date > 0) {
|
||||
return DateUtils.getExtendedRelativeTimeSpanString(this, Locale.getDefault(), mediaItem.date);
|
||||
} else {
|
||||
return getString(R.string.MediaPreviewActivity_draft);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
initializeMedia();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
restartItem = cleanupMedia();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
cursor = null;
|
||||
}
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
setIntent(intent);
|
||||
initializeResources();
|
||||
}
|
||||
|
||||
private void initializeViews() {
|
||||
mediaPager = findViewById(R.id.media_pager);
|
||||
mediaPager.setOffscreenPageLimit(1);
|
||||
mediaPager.setPageTransformer(true, new DepthPageTransformer());
|
||||
|
||||
viewPagerListener = new ViewPagerListener();
|
||||
mediaPager.addOnPageChangeListener(viewPagerListener);
|
||||
|
||||
albumRail = findViewById(R.id.media_preview_album_rail);
|
||||
albumRailAdapter = new MediaRailAdapter(GlideApp.with(this), this, false);
|
||||
|
||||
albumRail.setItemAnimator(null); // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682
|
||||
albumRail.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false));
|
||||
albumRail.setAdapter(albumRailAdapter);
|
||||
|
||||
detailsContainer = findViewById(R.id.media_preview_details_container);
|
||||
caption = findViewById(R.id.media_preview_caption);
|
||||
captionContainer = findViewById(R.id.media_preview_caption_container);
|
||||
playbackControlsContainer = findViewById(R.id.media_preview_playback_controls_container);
|
||||
|
||||
View toolbarLayout = findViewById(R.id.toolbar_layout);
|
||||
|
||||
anchorMarginsToBottomInsets(detailsContainer);
|
||||
|
||||
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), findViewById(R.id.toolbar));
|
||||
|
||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
Intent intent = getIntent();
|
||||
|
||||
threadId = intent.getLongExtra(THREAD_ID_EXTRA, NOT_IN_A_THREAD);
|
||||
cameFromAllMedia = intent.getBooleanExtra(HIDE_ALL_MEDIA_EXTRA, false);
|
||||
showThread = intent.getBooleanExtra(SHOW_THREAD_EXTRA, false);
|
||||
sorting = MediaDatabase.Sorting.values()[intent.getIntExtra(SORTING_EXTRA, 0)];
|
||||
|
||||
initialMediaUri = intent.getData();
|
||||
initialMediaType = intent.getType();
|
||||
initialMediaSize = intent.getLongExtra(SIZE_EXTRA, 0);
|
||||
initialCaption = intent.getStringExtra(CAPTION_EXTRA);
|
||||
leftIsRecent = intent.getBooleanExtra(LEFT_IS_RECENT_EXTRA, false);
|
||||
initialMediaIsVideoGif = intent.getBooleanExtra(IS_VIDEO_GIF, false);
|
||||
restartItem = -1;
|
||||
}
|
||||
|
||||
private void initializeObservers() {
|
||||
viewModel.getPreviewData().observe(this, previewData -> {
|
||||
if (previewData == null || mediaPager == null || mediaPager.getAdapter() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!((MediaItemAdapter) mediaPager.getAdapter()).hasFragmentFor(mediaPager.getCurrentItem())) {
|
||||
Log.d(TAG, "MediaItemAdapter wasn't ready. Posting again...");
|
||||
viewModel.resubmitPreviewData();
|
||||
}
|
||||
|
||||
View playbackControls = ((MediaItemAdapter) mediaPager.getAdapter()).getPlaybackControls(mediaPager.getCurrentItem());
|
||||
|
||||
if (previewData.getAlbumThumbnails().isEmpty() && previewData.getCaption() == null && playbackControls == null) {
|
||||
detailsContainer.setVisibility(View.GONE);
|
||||
} else {
|
||||
detailsContainer.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
albumRail.setVisibility(previewData.getAlbumThumbnails().isEmpty() ? View.GONE : View.VISIBLE);
|
||||
albumRailAdapter.setMedia(previewData.getAlbumThumbnails(), previewData.getActivePosition());
|
||||
albumRail.smoothScrollToPosition(previewData.getActivePosition());
|
||||
|
||||
captionContainer.setVisibility(previewData.getCaption() == null ? View.GONE : View.VISIBLE);
|
||||
caption.setText(previewData.getCaption());
|
||||
|
||||
if (playbackControls != null) {
|
||||
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
playbackControls.setLayoutParams(params);
|
||||
|
||||
playbackControlsContainer.removeAllViews();
|
||||
playbackControlsContainer.addView(playbackControls);
|
||||
} else {
|
||||
playbackControlsContainer.removeAllViews();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void initializeMedia() {
|
||||
if (!isContentTypeSupported(initialMediaType)) {
|
||||
Log.w(TAG, "Unsupported media type sent to MediaPreviewActivity, finishing.");
|
||||
Toast.makeText(getApplicationContext(), R.string.MediaPreviewActivity_unssuported_media_type, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
Log.i(TAG, "Loading Part URI: " + initialMediaUri);
|
||||
|
||||
if (isMediaInDb()) {
|
||||
LoaderManager.getInstance(this).restartLoader(0, null, this);
|
||||
} else {
|
||||
mediaPager.setAdapter(new SingleItemPagerAdapter(getSupportFragmentManager(), initialMediaUri, initialMediaType, initialMediaSize, initialMediaIsVideoGif));
|
||||
|
||||
if (initialCaption != null) {
|
||||
detailsContainer.setVisibility(View.VISIBLE);
|
||||
captionContainer.setVisibility(View.VISIBLE);
|
||||
caption.setText(initialCaption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int cleanupMedia() {
|
||||
int restartItem = mediaPager.getCurrentItem();
|
||||
|
||||
mediaPager.removeAllViews();
|
||||
mediaPager.setAdapter(null);
|
||||
viewModel.setCursor(this, null, leftIsRecent);
|
||||
|
||||
return restartItem;
|
||||
}
|
||||
|
||||
private void showOverview() {
|
||||
startActivity(MediaOverviewActivity.forThread(this, threadId));
|
||||
}
|
||||
|
||||
private void forward() {
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
|
||||
if (mediaItem != null) {
|
||||
MultiselectForwardFragmentArgs.create(
|
||||
this,
|
||||
threadId,
|
||||
mediaItem.uri,
|
||||
mediaItem.type,
|
||||
args -> MultiselectForwardFragment.showBottomSheet(getSupportFragmentManager(), args)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private void share() {
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
|
||||
if (mediaItem != null) {
|
||||
Uri publicUri = PartAuthority.getAttachmentPublicUri(mediaItem.uri);
|
||||
String mimeType = Intent.normalizeMimeType(mediaItem.type);
|
||||
Intent shareIntent = ShareCompat.IntentBuilder.from(this)
|
||||
.setStream(publicUri)
|
||||
.setType(mimeType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
startActivity(shareIntent);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "No activity existed to share the media.", e);
|
||||
Toast.makeText(this, R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
@SuppressLint("InlinedApi")
|
||||
private void saveToDisk() {
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
|
||||
if (mediaItem != null) {
|
||||
SaveAttachmentTask.showWarningDialog(this, (dialogInterface, i) -> {
|
||||
if (StorageUtil.canWriteToMediaStore()) {
|
||||
performSavetoDisk(mediaItem);
|
||||
return;
|
||||
}
|
||||
|
||||
Permissions.with(this)
|
||||
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
.ifNecessary()
|
||||
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
|
||||
.onAnyDenied(() -> Toast.makeText(this, R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show())
|
||||
.onAllGranted(() -> {
|
||||
performSavetoDisk(mediaItem);
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void performSavetoDisk(@NonNull MediaItem mediaItem) {
|
||||
SaveAttachmentTask saveTask = new SaveAttachmentTask(MediaPreviewActivity.this);
|
||||
long saveDate = (mediaItem.date > 0) ? mediaItem.date : System.currentTimeMillis();
|
||||
saveTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, new Attachment(mediaItem.uri, mediaItem.type, saveDate, null));
|
||||
}
|
||||
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
private void deleteMedia() {
|
||||
MediaItem mediaItem = getCurrentMediaItem();
|
||||
if (mediaItem == null || mediaItem.attachment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(this);
|
||||
builder.setIcon(R.drawable.ic_warning);
|
||||
builder.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title);
|
||||
builder.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message);
|
||||
builder.setCancelable(true);
|
||||
|
||||
builder.setPositiveButton(R.string.delete, (dialogInterface, which) -> {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
protected Void doInBackground(Void... voids) {
|
||||
AttachmentUtil.deleteAttachment(MediaPreviewActivity.this.getApplicationContext(),
|
||||
mediaItem.attachment);
|
||||
return null;
|
||||
}
|
||||
}.execute();
|
||||
|
||||
finish();
|
||||
});
|
||||
builder.setNegativeButton(android.R.string.cancel, null);
|
||||
builder.show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCreateOptionsMenu(Menu menu) {
|
||||
menu.clear();
|
||||
MenuInflater inflater = this.getMenuInflater();
|
||||
inflater.inflate(R.menu.media_preview, menu);
|
||||
|
||||
super.onCreateOptionsMenu(menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onPrepareOptionsMenu(Menu menu) {
|
||||
if (!isMediaInDb()) {
|
||||
menu.findItem(R.id.media_preview__overview).setVisible(false);
|
||||
menu.findItem(R.id.delete).setVisible(false);
|
||||
}
|
||||
|
||||
// Restricted to API26 because of MemoryFileUtil not supporting lower API levels well
|
||||
menu.findItem(R.id.media_preview__share).setVisible(Build.VERSION.SDK_INT >= 26);
|
||||
|
||||
if (cameFromAllMedia) {
|
||||
menu.findItem(R.id.media_preview__overview).setVisible(false);
|
||||
}
|
||||
|
||||
super.onPrepareOptionsMenu(menu);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
int itemId = item.getItemId();
|
||||
|
||||
if (itemId == R.id.media_preview__overview) { showOverview(); return true; }
|
||||
if (itemId == R.id.media_preview__forward) { forward(); return true; }
|
||||
if (itemId == R.id.media_preview__share) { share(); return true; }
|
||||
if (itemId == R.id.save) { saveToDisk(); return true; }
|
||||
if (itemId == R.id.delete) { deleteMedia(); return true; }
|
||||
if (itemId == android.R.id.home) { finish(); return true; }
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isMediaInDb() {
|
||||
return threadId != NOT_IN_A_THREAD;
|
||||
}
|
||||
|
||||
private @Nullable MediaItem getCurrentMediaItem() {
|
||||
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
|
||||
|
||||
if (adapter != null) {
|
||||
return adapter.getMediaItemFor(mediaPager.getCurrentItem());
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isContentTypeSupported(final String contentType) {
|
||||
return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Loader<Pair<Cursor, Integer>> onCreateLoader(int id, Bundle args) {
|
||||
return new PagingMediaLoader(this, threadId, initialMediaUri, leftIsRecent, sorting);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadFinished(@NonNull Loader<Pair<Cursor, Integer>> loader, @Nullable Pair<Cursor, Integer> data) {
|
||||
if (data != null) {
|
||||
if (data.first == cursor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
cursor = Objects.requireNonNull(data.first);
|
||||
|
||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||
|
||||
int mediaPosition = Objects.requireNonNull(data.second);
|
||||
|
||||
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
|
||||
if (oldAdapter == null) {
|
||||
CursorPagerAdapter adapter = new CursorPagerAdapter(getSupportFragmentManager(), this, cursor, mediaPosition, leftIsRecent);
|
||||
mediaPager.setAdapter(adapter);
|
||||
adapter.setActive(true);
|
||||
} else {
|
||||
oldAdapter.setCursor(cursor, mediaPosition);
|
||||
oldAdapter.setActive(true);
|
||||
}
|
||||
|
||||
if (oldAdapter == null || restartItem >= 0) {
|
||||
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
||||
mediaPager.setCurrentItem(item);
|
||||
|
||||
if (item == 0) {
|
||||
viewPagerListener.onPageSelected(0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mediaNotAvailable();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoaderReset(@NonNull Loader<Pair<Cursor, Integer>> loader) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean singleTapOnMedia() {
|
||||
fullscreenHelper.toggleUiVisibility();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mediaNotAvailable() {
|
||||
Toast.makeText(this, R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMediaReady() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||
return voiceNoteMediaController;
|
||||
}
|
||||
|
||||
private class ViewPagerListener extends ExtendedOnPageChangedListener {
|
||||
|
||||
@Override
|
||||
public void onPageSelected(int position) {
|
||||
super.onPageSelected(position);
|
||||
|
||||
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
|
||||
|
||||
if (adapter != null) {
|
||||
MediaItem item = adapter.getMediaItemFor(position);
|
||||
if (item != null && item.recipient != null) {
|
||||
item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
|
||||
}
|
||||
|
||||
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
|
||||
initializeActionBar();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onPageUnselected(int position) {
|
||||
MediaItemAdapter adapter = (MediaItemAdapter)mediaPager.getAdapter();
|
||||
|
||||
if (adapter != null) {
|
||||
MediaItem item = adapter.getMediaItemFor(position);
|
||||
if (item != null && item.recipient != null) {
|
||||
item.recipient.live().removeObservers(MediaPreviewActivity.this);
|
||||
}
|
||||
|
||||
adapter.pause(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class SingleItemPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
|
||||
|
||||
private final Uri uri;
|
||||
private final String mediaType;
|
||||
private final long size;
|
||||
private final boolean isVideoGif;
|
||||
|
||||
private MediaPreviewFragment mediaPreviewFragment;
|
||||
|
||||
SingleItemPagerAdapter(@NonNull FragmentManager fragmentManager,
|
||||
@NonNull Uri uri,
|
||||
@NonNull String mediaType,
|
||||
long size,
|
||||
boolean isVideoGif)
|
||||
{
|
||||
super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
|
||||
this.uri = uri;
|
||||
this.mediaType = mediaType;
|
||||
this.size = size;
|
||||
this.isVideoGif = isVideoGif;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Fragment getItem(int position) {
|
||||
mediaPreviewFragment = MediaPreviewFragment.newInstance(uri, mediaType, size, true, isVideoGif);
|
||||
return mediaPreviewFragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
|
||||
if (mediaPreviewFragment != null) {
|
||||
mediaPreviewFragment.cleanUp();
|
||||
mediaPreviewFragment = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable MediaItem getMediaItemFor(int position) {
|
||||
return new MediaItem(null, null, null, uri, mediaType, -1, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause(int position) {
|
||||
if (mediaPreviewFragment != null) {
|
||||
mediaPreviewFragment.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View getPlaybackControls(int position) {
|
||||
if (mediaPreviewFragment != null) {
|
||||
return mediaPreviewFragment.getPlaybackControls();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasFragmentFor(int position) {
|
||||
return mediaPreviewFragment != null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor, (view, insets) -> {
|
||||
ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
|
||||
|
||||
layoutParams.setMargins(insets.getSystemWindowInsetLeft(),
|
||||
layoutParams.topMargin,
|
||||
insets.getSystemWindowInsetRight(),
|
||||
insets.getSystemWindowInsetBottom());
|
||||
|
||||
view.setLayoutParams(layoutParams);
|
||||
|
||||
return insets;
|
||||
});
|
||||
}
|
||||
|
||||
private static class CursorPagerAdapter extends FragmentStatePagerAdapter implements MediaItemAdapter {
|
||||
|
||||
@SuppressLint("UseSparseArrays")
|
||||
private final Map<Integer, MediaPreviewFragment> mediaFragments = new HashMap<>();
|
||||
|
||||
private final Context context;
|
||||
private final boolean leftIsRecent;
|
||||
|
||||
private boolean active;
|
||||
private Cursor cursor;
|
||||
private int autoPlayPosition;
|
||||
|
||||
CursorPagerAdapter(@NonNull FragmentManager fragmentManager,
|
||||
@NonNull Context context,
|
||||
@NonNull Cursor cursor,
|
||||
int autoPlayPosition,
|
||||
boolean leftIsRecent)
|
||||
{
|
||||
super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
|
||||
this.context = context.getApplicationContext();
|
||||
this.cursor = cursor;
|
||||
this.autoPlayPosition = autoPlayPosition;
|
||||
this.leftIsRecent = leftIsRecent;
|
||||
}
|
||||
|
||||
public void setActive(boolean active) {
|
||||
this.active = active;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setCursor(@NonNull Cursor cursor, int autoPlayPosition) {
|
||||
this.cursor = cursor;
|
||||
this.autoPlayPosition = autoPlayPosition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
if (!active) return 0;
|
||||
else return cursor.getCount();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Fragment getItem(int position) {
|
||||
boolean autoPlay = autoPlayPosition == position;
|
||||
int cursorPosition = getCursorPosition(position);
|
||||
|
||||
autoPlayPosition = -1;
|
||||
|
||||
cursor.moveToPosition(cursorPosition);
|
||||
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(context, cursor);
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
MediaPreviewFragment fragment = MediaPreviewFragment.newInstance(attachment, autoPlay);
|
||||
|
||||
mediaFragments.put(position, fragment);
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void destroyItem(@NonNull ViewGroup container, int position, @NonNull Object object) {
|
||||
MediaPreviewFragment removed = mediaFragments.remove(position);
|
||||
|
||||
if (removed != null) {
|
||||
removed.cleanUp();
|
||||
}
|
||||
|
||||
super.destroyItem(container, position, object);
|
||||
}
|
||||
|
||||
public @Nullable MediaItem getMediaItemFor(int position) {
|
||||
int cursorPosition = getCursorPosition(position);
|
||||
|
||||
if (cursor.isClosed() || cursorPosition < 0) {
|
||||
Log.w(TAG, "Invalid cursor state! Closed: " + cursor.isClosed() + " Position: " + cursorPosition);
|
||||
return null;
|
||||
}
|
||||
|
||||
cursor.moveToPosition(cursorPosition);
|
||||
|
||||
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
|
||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||
RecipientId recipientId = mediaRecord.getRecipientId();
|
||||
RecipientId threadRecipientId = mediaRecord.getThreadRecipientId();
|
||||
|
||||
return new MediaItem(Recipient.live(recipientId).get(),
|
||||
Recipient.live(threadRecipientId).get(),
|
||||
attachment,
|
||||
Objects.requireNonNull(attachment.getUri()),
|
||||
mediaRecord.getContentType(),
|
||||
mediaRecord.getDate(),
|
||||
mediaRecord.isOutgoing());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pause(int position) {
|
||||
MediaPreviewFragment mediaView = mediaFragments.get(position);
|
||||
if (mediaView != null) mediaView.pause();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable View getPlaybackControls(int position) {
|
||||
MediaPreviewFragment mediaView = mediaFragments.get(position);
|
||||
if (mediaView != null) return mediaView.getPlaybackControls();
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasFragmentFor(int position) {
|
||||
return mediaFragments.containsKey(position);
|
||||
}
|
||||
|
||||
private int getCursorPosition(int position) {
|
||||
if (leftIsRecent) return position;
|
||||
else return cursor.getCount() - 1 - position;
|
||||
}
|
||||
}
|
||||
|
||||
private static class MediaItem {
|
||||
private final @Nullable Recipient recipient;
|
||||
private final @Nullable Recipient threadRecipient;
|
||||
private final @Nullable DatabaseAttachment attachment;
|
||||
private final @NonNull Uri uri;
|
||||
private final @NonNull String type;
|
||||
private final long date;
|
||||
private final boolean outgoing;
|
||||
|
||||
private MediaItem(@Nullable Recipient recipient,
|
||||
@Nullable Recipient threadRecipient,
|
||||
@Nullable DatabaseAttachment attachment,
|
||||
@NonNull Uri uri,
|
||||
@NonNull String type,
|
||||
long date,
|
||||
boolean outgoing)
|
||||
{
|
||||
this.recipient = recipient;
|
||||
this.threadRecipient = threadRecipient;
|
||||
this.attachment = attachment;
|
||||
this.uri = uri;
|
||||
this.type = type;
|
||||
this.date = date;
|
||||
this.outgoing = outgoing;
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaItemAdapter {
|
||||
@Nullable MediaItem getMediaItemFor(int position);
|
||||
void pause(int position);
|
||||
@Nullable View getPlaybackControls(int position);
|
||||
boolean hasFragmentFor(int position);
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,28 @@ import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementRepository;
|
||||
import org.thoughtcrime.securesms.contacts.management.ContactsManagementViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
@@ -33,42 +50,70 @@ 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.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Activity container for starting a new conversation.
|
||||
*
|
||||
* @author Moxie Marlinspike
|
||||
*
|
||||
*/
|
||||
public class NewConversationActivity extends ContactSelectionActivity
|
||||
implements ContactSelectionListFragment.ListCallback
|
||||
implements ContactSelectionListFragment.ListCallback, ContactSelectionListFragment.OnItemLongClickListener
|
||||
{
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(NewConversationActivity.class);
|
||||
|
||||
private ContactsManagementViewModel viewModel;
|
||||
private ActivityResultLauncher<Intent> contactLauncher;
|
||||
|
||||
private final LifecycleDisposable disposables = new LifecycleDisposable();
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle, boolean ready) {
|
||||
super.onCreate(bundle, ready);
|
||||
assert getSupportActionBar() != null;
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
|
||||
|
||||
disposables.bindTo(this);
|
||||
|
||||
ContactsManagementRepository repository = new ContactsManagementRepository(this);
|
||||
ContactsManagementViewModel.Factory factory = new ContactsManagementViewModel.Factory(repository);
|
||||
|
||||
contactLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), activityResult -> {
|
||||
if (activityResult.getResultCode() == RESULT_OK) {
|
||||
handleManualRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
viewModel = new ViewModelProvider(this, factory).get(ContactsManagementViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||
boolean smsSupported = SignalStore.misc().getSmsExportPhase().allowSmsFeatures();
|
||||
|
||||
if (recipientId.isPresent()) {
|
||||
launch(Recipient.resolved(recipientId.get()));
|
||||
} else {
|
||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
||||
|
||||
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(getApplication())) {
|
||||
if (SignalStore.account().isRegistered()) {
|
||||
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
|
||||
|
||||
AlertDialog progress = SimpleProgressDialog.show(this);
|
||||
@@ -83,15 +128,31 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
resolved = Recipient.resolved(resolved.getId());
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}, resolved -> {
|
||||
progress.dismiss();
|
||||
launch(resolved);
|
||||
|
||||
if (resolved != null) {
|
||||
if (smsSupported || resolved.isRegistered() && resolved.hasServiceId()) {
|
||||
launch(resolved);
|
||||
} else {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, resolved.getDisplayName(this)))
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
} else {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setMessage(R.string.NetworkFailure__network_error_check_your_connection_and_try_again)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
} else if (smsSupported) {
|
||||
launch(Recipient.external(this, number));
|
||||
}
|
||||
}
|
||||
@@ -120,10 +181,18 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home: super.onBackPressed(); return true;
|
||||
case R.id.menu_refresh: handleManualRefresh(); return true;
|
||||
case R.id.menu_new_group: handleCreateGroup(); return true;
|
||||
case R.id.menu_invite: handleInvite(); return true;
|
||||
case android.R.id.home:
|
||||
super.onBackPressed();
|
||||
return true;
|
||||
case R.id.menu_refresh:
|
||||
handleManualRefresh();
|
||||
return true;
|
||||
case R.id.menu_new_group:
|
||||
handleCreateGroup();
|
||||
return true;
|
||||
case R.id.menu_invite:
|
||||
handleInvite();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -162,4 +231,151 @@ public class NewConversationActivity extends ContactSelectionActivity
|
||||
handleCreateGroup();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onLongClick(ContactSelectionListItem contactSelectionListItem, RecyclerView recyclerView) {
|
||||
RecipientId recipientId = contactSelectionListItem.getRecipientId().orElse(null);
|
||||
if (recipientId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<ActionItem> actions = generateContextualActionsForRecipient(recipientId);
|
||||
if (actions.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
new SignalContextMenu.Builder(contactSelectionListItem, (ViewGroup) contactSelectionListItem.getRootView())
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.preferredHorizontalPosition(SignalContextMenu.HorizontalPosition.START)
|
||||
.offsetX((int) DimensionUnit.DP.toPixels(12))
|
||||
.offsetY((int) DimensionUnit.DP.toPixels(12))
|
||||
.onDismiss(() -> recyclerView.suppressLayout(false))
|
||||
.show(actions);
|
||||
|
||||
recyclerView.suppressLayout(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private @NonNull List<ActionItem> generateContextualActionsForRecipient(@NonNull RecipientId recipientId) {
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
|
||||
return Stream.of(
|
||||
createMessageActionItem(recipient),
|
||||
createAudioCallActionItem(recipient),
|
||||
createVideoCallActionItem(recipient),
|
||||
createRemoveActionItem(recipient),
|
||||
createBlockActionItem(recipient)
|
||||
).filter(Objects::nonNull).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private @NonNull ActionItem createMessageActionItem(@NonNull Recipient recipient) {
|
||||
return new ActionItem(
|
||||
R.drawable.ic_chat_message_24,
|
||||
getString(R.string.NewConversationActivity__message),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> startActivity(ConversationIntents.createBuilder(this, recipient.getId(), -1L).build())
|
||||
);
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createAudioCallActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (recipient.isRegistered() || (SignalStore.misc().getSmsExportPhase().allowSmsFeatures())) {
|
||||
return new ActionItem(
|
||||
R.drawable.ic_phone_right_24,
|
||||
getString(R.string.NewConversationActivity__audio_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVoiceCall(this, recipient)
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createVideoCallActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf() || recipient.isMmsGroup() || !recipient.isRegistered()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_video_call_24,
|
||||
getString(R.string.NewConversationActivity__video_call),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> CommunicationActions.startVideoCall(this, recipient)
|
||||
);
|
||||
}
|
||||
|
||||
private @Nullable ActionItem createRemoveActionItem(@NonNull Recipient recipient) {
|
||||
if (!FeatureFlags.hideContacts() || recipient.isSelf() || recipient.isGroup()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_minus_circle_20, // TODO [alex] -- correct asset
|
||||
getString(R.string.NewConversationActivity__remove),
|
||||
R.color.signal_colorOnSurface,
|
||||
() -> {
|
||||
if (recipient.isSystemContact()) {
|
||||
displayIsInSystemContactsDialog(recipient);
|
||||
} else {
|
||||
displayRemovalDialog(recipient);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
private @Nullable ActionItem createBlockActionItem(@NonNull Recipient recipient) {
|
||||
if (recipient.isSelf()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ActionItem(
|
||||
R.drawable.ic_block_tinted_24,
|
||||
getString(R.string.NewConversationActivity__block),
|
||||
R.color.signal_colorError,
|
||||
() -> BlockUnblockDialog.showBlockFor(this,
|
||||
this.getLifecycle(),
|
||||
recipient,
|
||||
() -> {
|
||||
disposables.add(viewModel.blockContact(recipient).subscribe(() -> {
|
||||
displaySnackbar(R.string.NewConversationActivity__s_has_been_blocked, recipient.getDisplayName(this));
|
||||
contactsFragment.reset();
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void displayIsInSystemContactsDialog(@NonNull Recipient recipient) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.NewConversationActivity__unable_to_remove_s, recipient.getShortDisplayName(this)))
|
||||
.setMessage(R.string.NewConversationActivity__this_person_is_saved_to_your)
|
||||
.setPositiveButton(R.string.NewConversationActivity__view_contact,
|
||||
(dialog, which) -> contactLauncher.launch(new Intent(Intent.ACTION_VIEW, recipient.getContactUri()))
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void displayRemovalDialog(@NonNull Recipient recipient) {
|
||||
new MaterialAlertDialogBuilder(this)
|
||||
.setTitle(getString(R.string.NewConversationActivity__remove_s, recipient.getShortDisplayName(this)))
|
||||
.setMessage(R.string.NewConversationActivity__you_wont_see_this_person)
|
||||
.setPositiveButton(R.string.NewConversationActivity__remove,
|
||||
(dialog, which) -> {
|
||||
disposables.add(viewModel.hideContact(recipient).subscribe(() -> {
|
||||
displaySnackbar(R.string.NewConversationActivity__s_has_been_removed, recipient.getDisplayName(this));
|
||||
}));
|
||||
}
|
||||
)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void displaySnackbar(@StringRes int message, Object ... formatArgs) {
|
||||
Snackbar.make(findViewById(android.R.id.content), getString(message, formatArgs), Snackbar.LENGTH_SHORT).show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import android.widget.TextView;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.biometric.BiometricManager;
|
||||
import androidx.biometric.BiometricManager.Authenticators;
|
||||
import androidx.biometric.BiometricPrompt;
|
||||
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
@@ -64,6 +63,8 @@ import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.SupportEmailUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* Activity that prompts for a user's passphrase.
|
||||
*
|
||||
@@ -72,8 +73,6 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
|
||||
private static final String TAG = Log.tag(PassphrasePromptActivity.class);
|
||||
private static final int BIOMETRIC_AUTHENTICATORS = Authenticators.BIOMETRIC_STRONG | Authenticators.BIOMETRIC_WEAK;
|
||||
private static final int ALLOWED_AUTHENTICATORS = BIOMETRIC_AUTHENTICATORS | Authenticators.DEVICE_CREDENTIAL;
|
||||
private static final short AUTHENTICATE_REQUEST_CODE = 1007;
|
||||
private static final String BUNDLE_ALREADY_SHOWN = "bundle_already_shown";
|
||||
public static final String FROM_FOREGROUND = "from_foreground";
|
||||
@@ -90,9 +89,9 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
private ImageButton hideButton;
|
||||
private AnimatingToggle visibilityToggle;
|
||||
|
||||
private BiometricManager biometricManager;
|
||||
private BiometricPrompt biometricPrompt;
|
||||
private BiometricPrompt.PromptInfo biometricPromptInfo;
|
||||
private BiometricManager biometricManager;
|
||||
private BiometricPrompt biometricPrompt;
|
||||
private BiometricDeviceAuthentication biometricAuth;
|
||||
|
||||
private boolean authenticated;
|
||||
private boolean hadFailure;
|
||||
@@ -249,12 +248,12 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
lockScreenButton = findViewById(R.id.lock_screen_auth_container);
|
||||
biometricManager = BiometricManager.from(this);
|
||||
biometricPrompt = new BiometricPrompt(this, new BiometricAuthenticationListener());
|
||||
biometricPromptInfo = new BiometricPrompt.PromptInfo
|
||||
.Builder()
|
||||
.setAllowedAuthenticators(ALLOWED_AUTHENTICATORS)
|
||||
.setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal))
|
||||
.build();
|
||||
|
||||
BiometricPrompt.PromptInfo biometricPromptInfo = new BiometricPrompt.PromptInfo
|
||||
.Builder()
|
||||
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
|
||||
.setTitle(getString(R.string.PassphrasePromptActivity_unlock_signal))
|
||||
.build();
|
||||
biometricAuth = new BiometricDeviceAuthentication(biometricManager, biometricPrompt, biometricPromptInfo);
|
||||
setSupportActionBar(toolbar);
|
||||
getSupportActionBar().setTitle("");
|
||||
|
||||
@@ -279,7 +278,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
private void setLockTypeVisibility() {
|
||||
if (TextSecurePreferences.isScreenLockEnabled(this)) {
|
||||
passphraseAuthContainer.setVisibility(View.GONE);
|
||||
fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE
|
||||
fingerprintPrompt.setVisibility(biometricManager.canAuthenticate(BiometricDeviceAuthentication.BIOMETRIC_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS ? View.VISIBLE
|
||||
: View.GONE);
|
||||
lockScreenButton.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
@@ -290,33 +289,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
}
|
||||
|
||||
private void resumeScreenLock(boolean force) {
|
||||
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
|
||||
|
||||
assert keyguardManager != null;
|
||||
|
||||
if (!keyguardManager.isKeyguardSecure()) {
|
||||
Log.w(TAG ,"Keyguard not secure...");
|
||||
handleAuthenticated();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT != 29 && biometricManager.canAuthenticate(ALLOWED_AUTHENTICATORS) == BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
if (force) {
|
||||
Log.i(TAG, "Listening for biometric authentication...");
|
||||
biometricPrompt.authenticate(biometricPromptInfo);
|
||||
} else {
|
||||
Log.i(TAG, "Skipping show system biometric dialog unless forced");
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= 21) {
|
||||
if (force) {
|
||||
Log.i(TAG, "firing intent...");
|
||||
Intent intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
|
||||
startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
|
||||
} else {
|
||||
Log.i(TAG, "Skipping firing intent unless forced");
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "Not compatible...");
|
||||
if (!biometricAuth.authenticate(getApplicationContext(), force, this::showConfirmDeviceCredentialIntent)) {
|
||||
handleAuthenticated();
|
||||
}
|
||||
}
|
||||
@@ -332,6 +305,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
||||
body);
|
||||
}
|
||||
|
||||
public Unit showConfirmDeviceCredentialIntent() {
|
||||
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
|
||||
Intent intent = null;
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
intent = keyguardManager.createConfirmDeviceCredentialIntent(getString(R.string.PassphrasePromptActivity_unlock_signal), "");
|
||||
}
|
||||
startActivityForResult(intent, AUTHENTICATE_REQUEST_CODE);
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
private class PassphraseActionListener implements TextView.OnEditorActionListener {
|
||||
@Override
|
||||
public boolean onEditorAction(TextView exampleView, int actionId, KeyEvent keyEvent) {
|
||||
|
||||
@@ -20,17 +20,20 @@ import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateKbsPinActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrationActivity;
|
||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||
import org.thoughtcrime.securesms.pin.PinRestoreActivity;
|
||||
import org.thoughtcrime.securesms.profiles.edit.EditProfileActivity;
|
||||
import org.thoughtcrime.securesms.profiles.username.AddAUsernameActivity;
|
||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.AppStartup;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
import java.util.Locale;
|
||||
@@ -52,6 +55,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||
private static final int STATE_CREATE_USERNAME = 11;
|
||||
|
||||
private SignalServiceNetworkAccess networkAccess;
|
||||
private BroadcastReceiver clearKeyReceiver;
|
||||
@@ -156,6 +160,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||
case STATE_CREATE_USERNAME: return getCreateUsernameIntent();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -175,6 +180,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (userMustSetProfileName()) {
|
||||
return STATE_CREATE_PROFILE_NAME;
|
||||
} else if (shouldAskUserToCreateUsername()) {
|
||||
return STATE_CREATE_USERNAME;
|
||||
} else if (userMustCreateSignalPin()) {
|
||||
return STATE_CREATE_SIGNAL_PIN;
|
||||
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
|
||||
@@ -200,6 +207,13 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||
}
|
||||
|
||||
private boolean shouldAskUserToCreateUsername() {
|
||||
return FeatureFlags.usernames() &&
|
||||
FeatureFlags.phoneNumberPrivacy() &&
|
||||
!SignalStore.uiHints().hasSetOrSkippedUsernameCreation() &&
|
||||
SignalStore.phoneNumberPrivacy().getPhoneNumberListingMode() == PhoneNumberPrivacyValues.PhoneNumberListingMode.UNLISTED;
|
||||
}
|
||||
|
||||
private Intent getCreatePassphraseIntent() {
|
||||
return getRoutedIntent(PassphraseCreateActivity.class, getIntent());
|
||||
}
|
||||
@@ -238,7 +252,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
}
|
||||
|
||||
private Intent getCreateProfileNameIntent() {
|
||||
return getRoutedIntent(EditProfileActivity.class, getIntent());
|
||||
Intent intent = EditProfileActivity.getIntentForUserProfile(this);
|
||||
return getRoutedIntent(intent, getIntent());
|
||||
}
|
||||
|
||||
private Intent getOldDeviceTransferIntent() {
|
||||
@@ -258,6 +273,15 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
||||
return ChangeNumberLockActivity.createIntent(this);
|
||||
}
|
||||
|
||||
private Intent getCreateUsernameIntent() {
|
||||
return getRoutedIntent(AddAUsernameActivity.class, getIntent());
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Intent destination, @Nullable Intent nextIntent) {
|
||||
if (nextIntent != null) destination.putExtra("next_intent", nextIntent);
|
||||
return destination;
|
||||
}
|
||||
|
||||
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
|
||||
final Intent intent = new Intent(this, destination);
|
||||
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
|
||||
|
||||
@@ -24,7 +24,6 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Rect;
|
||||
import android.media.AudioManager;
|
||||
import android.os.Build;
|
||||
@@ -40,15 +39,18 @@ import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.window.DisplayFeature;
|
||||
import androidx.window.FoldingFeature;
|
||||
import androidx.window.WindowLayoutInfo;
|
||||
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter;
|
||||
import androidx.window.layout.DisplayFeature;
|
||||
import androidx.window.layout.FoldingFeature;
|
||||
import androidx.window.layout.WindowInfoTracker;
|
||||
import androidx.window.layout.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.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
@@ -107,20 +109,21 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
public static final String END_CALL_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".END_CALL_ACTION";
|
||||
|
||||
public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE";
|
||||
public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN";
|
||||
|
||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
|
||||
private DeviceOrientationMonitor deviceOrientationMonitor;
|
||||
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private boolean hasWarnedAboutBluetooth;
|
||||
private androidx.window.WindowManager windowManager;
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
private FullscreenHelper fullscreenHelper;
|
||||
private WebRtcCallView callScreen;
|
||||
private TooltipPopup videoTooltip;
|
||||
private WebRtcCallViewModel viewModel;
|
||||
private boolean enableVideoIfAvailable;
|
||||
private boolean hasWarnedAboutBluetooth;
|
||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||
private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -133,7 +136,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@SuppressLint("SourceLockedOrientationActivity")
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate()");
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -158,10 +161,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
|
||||
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
||||
|
||||
windowManager = new androidx.window.WindowManager(this);
|
||||
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
|
||||
|
||||
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
|
||||
windowInfoTrackerCallbackAdapter = new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this));
|
||||
windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
|
||||
|
||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
@@ -185,11 +188,25 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
if (!EventBus.getDefault().isRegistered(this)) {
|
||||
EventBus.getDefault().register(this);
|
||||
}
|
||||
|
||||
WebRtcViewModel rtcViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class);
|
||||
if (rtcViewModel == null) {
|
||||
Log.w(TAG, "Activity resumed without service event, perform delay destroy");
|
||||
ThreadUtil.runOnMainDelayed(() -> {
|
||||
WebRtcViewModel delayRtcViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class);
|
||||
if (delayRtcViewModel == null) {
|
||||
Log.w(TAG, "Activity still without service event, finishing activity");
|
||||
finish();
|
||||
} else {
|
||||
Log.i(TAG, "Event found after delay");
|
||||
}
|
||||
}, TimeUnit.SECONDS.toMillis(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewIntent(Intent intent) {
|
||||
Log.i(TAG, "onNewIntent");
|
||||
Log.i(TAG, "onNewIntent(" + intent.getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
super.onNewIntent(intent);
|
||||
processIntent(intent);
|
||||
}
|
||||
@@ -234,7 +251,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
super.onDestroy();
|
||||
windowManager.unregisterLayoutChangeCallback(windowLayoutInfoConsumer);
|
||||
windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer);
|
||||
EventBus.getDefault().unregister(this);
|
||||
}
|
||||
|
||||
@@ -256,12 +273,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
|
||||
viewModel.setIsInPipMode(isInPictureInPictureMode);
|
||||
participantUpdateWindow.setEnabled(!isInPictureInPictureMode);
|
||||
}
|
||||
|
||||
private boolean enterPipModeIfPossible() {
|
||||
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
|
||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||
@@ -341,6 +352,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
|
||||
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> ApplicationDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
|
||||
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
|
||||
|
||||
addOnPictureInPictureModeChangedListener(info -> {
|
||||
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
|
||||
participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode());
|
||||
});
|
||||
}
|
||||
|
||||
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package org.thoughtcrime.securesms.animation;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.viewpager.widget.ViewPager;
|
||||
|
||||
/**
|
||||
* Based on https://developer.android.com/training/animation/screen-slide#depth-page
|
||||
*/
|
||||
public final class DepthPageTransformer implements ViewPager.PageTransformer {
|
||||
private static final float MIN_SCALE = 0.75f;
|
||||
|
||||
public void transformPage(@NonNull View view, float position) {
|
||||
final int pageWidth = view.getWidth();
|
||||
|
||||
if (position < -1f) {
|
||||
view.setAlpha(0f);
|
||||
|
||||
} else if (position <= 0f) {
|
||||
view.setAlpha(1f);
|
||||
view.setTranslationX(0f);
|
||||
view.setScaleX(1f);
|
||||
view.setScaleY(1f);
|
||||
|
||||
} else if (position <= 1f) {
|
||||
view.setAlpha(1f - position);
|
||||
|
||||
view.setTranslationX(pageWidth * -position);
|
||||
|
||||
final float scaleFactor = MIN_SCALE + (1f - MIN_SCALE) * (1f - Math.abs(position));
|
||||
|
||||
view.setScaleX(scaleFactor);
|
||||
view.setScaleY(scaleFactor);
|
||||
|
||||
} else {
|
||||
view.setAlpha(0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,14 @@ 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.view.ViewCompat
|
||||
import androidx.transition.Transition
|
||||
import androidx.transition.TransitionValues
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
|
||||
@RequiresApi(21)
|
||||
class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||
class FabElevationFadeTransform(context: Context, attrs: AttributeSet) : Transition(context, attrs) {
|
||||
|
||||
companion object {
|
||||
private const val ELEVATION = "CrossfaderTransition.ELEVATION"
|
||||
@@ -19,23 +18,23 @@ class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transi
|
||||
|
||||
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is FloatingActionButton) {
|
||||
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||
transitionValues.values[ELEVATION] = ViewCompat.getElevation(transitionValues.view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||
if (transitionValues.view is FloatingActionButton) {
|
||||
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||
transitionValues.values[ELEVATION] = ViewCompat.getElevation(transitionValues.view)
|
||||
}
|
||||
}
|
||||
|
||||
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||
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
|
||||
val startElevation = ViewCompat.getElevation(startValues.view)
|
||||
val endElevation = ViewCompat.getElevation(endValues.view)
|
||||
if (startElevation == endElevation) {
|
||||
return null
|
||||
}
|
||||
@@ -46,7 +45,7 @@ class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transi
|
||||
).apply {
|
||||
addUpdateListener {
|
||||
val elevation = it.animatedValue as Float
|
||||
endValues.view.elevation = elevation
|
||||
ViewCompat.setElevation(endValues.view, elevation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,12 @@ public abstract class Attachment {
|
||||
|
||||
public boolean isInProgress() {
|
||||
return transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE &&
|
||||
transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
|
||||
transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED &&
|
||||
transferState != AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public boolean isPermanentlyFailed() {
|
||||
return transferState == AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms.audio;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
@@ -25,13 +26,15 @@ public class AudioRecorder {
|
||||
|
||||
private static final ExecutorService executor = SignalExecutors.newCachedSingleThreadExecutor("signal-AudioRecorder");
|
||||
|
||||
private final Context context;
|
||||
private final Context context;
|
||||
private final AudioRecorderFocusManager audioFocusManager;
|
||||
|
||||
private Recorder recorder;
|
||||
private Uri captureUri;
|
||||
|
||||
public AudioRecorder(@NonNull Context context) {
|
||||
this.context = context;
|
||||
audioFocusManager = AudioRecorderFocusManager.create(context, focusChange -> stopRecording());
|
||||
}
|
||||
|
||||
public void startRecording() {
|
||||
@@ -52,6 +55,10 @@ public class AudioRecorder {
|
||||
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||
|
||||
recorder = Build.VERSION.SDK_INT >= 26 ? new MediaRecorderWrapper() : new AudioCodec();
|
||||
int focusResult = audioFocusManager.requestAudioFocus();
|
||||
if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
||||
Log.w(TAG, "Could not gain audio focus. Received result code " + focusResult);
|
||||
}
|
||||
recorder.start(fds[1]);
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
@@ -70,6 +77,7 @@ public class AudioRecorder {
|
||||
return;
|
||||
}
|
||||
|
||||
audioFocusManager.abandonAudioFocus();
|
||||
recorder.stop();
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.thoughtcrime.securesms.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioManager.OnAudioFocusChangeListener
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil
|
||||
|
||||
abstract class AudioRecorderFocusManager(val context: Context) {
|
||||
protected val audioManager: AudioManager = ServiceUtil.getAudioManager(context)
|
||||
|
||||
abstract fun requestAudioFocus(): Int
|
||||
abstract fun abandonAudioFocus(): Int
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun create(context: Context, changeListener: OnAudioFocusChangeListener): AudioRecorderFocusManager {
|
||||
return if (Build.VERSION.SDK_INT >= 26) {
|
||||
AudioRecorderFocusManager26(context, changeListener)
|
||||
} else {
|
||||
AudioRecorderFocusManagerLegacy(context, changeListener)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(26)
|
||||
private class AudioRecorderFocusManager26(context: Context, changeListener: OnAudioFocusChangeListener) : AudioRecorderFocusManager(context) {
|
||||
val audioFocusRequest: AudioFocusRequest
|
||||
|
||||
init {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
||||
.build()
|
||||
audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
.setAudioAttributes(audioAttributes)
|
||||
.setOnAudioFocusChangeListener(changeListener)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun requestAudioFocus(): Int {
|
||||
return audioManager.requestAudioFocus(audioFocusRequest)
|
||||
}
|
||||
|
||||
override fun abandonAudioFocus(): Int {
|
||||
return audioManager.abandonAudioFocusRequest(audioFocusRequest)
|
||||
}
|
||||
}
|
||||
|
||||
private class AudioRecorderFocusManagerLegacy(context: Context, val changeListener: OnAudioFocusChangeListener) : AudioRecorderFocusManager(context) {
|
||||
override fun requestAudioFocus(): Int {
|
||||
return audioManager.requestAudioFocus(changeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
|
||||
}
|
||||
|
||||
override fun abandonAudioFocus(): Int {
|
||||
return audioManager.abandonAudioFocus(changeListener)
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
)
|
||||
}
|
||||
clearButton.setOnClickListener { viewModel.clear() }
|
||||
clearButton.setOnClickListener { viewModel.clearAvatar() }
|
||||
|
||||
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
|
||||
val text = AvatarBundler.extractText(bundle)
|
||||
|
||||
@@ -32,7 +32,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
fun clearAvatar() {
|
||||
store.update {
|
||||
val avatar = getDefaultAvatarFromRepository()
|
||||
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.thoughtcrime.securesms.backup;
|
||||
|
||||
public class BackupEvent {
|
||||
public enum Type {
|
||||
PROGRESS,
|
||||
PROGRESS_VERIFYING,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final long count;
|
||||
private final long estimatedTotalCount;
|
||||
|
||||
public BackupEvent(Type type, long count, long estimatedTotalCount) {
|
||||
this.type = type;
|
||||
this.count = count;
|
||||
this.estimatedTotalCount = estimatedTotalCount;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public long getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public long getEstimatedTotalCount() {
|
||||
return estimatedTotalCount;
|
||||
}
|
||||
|
||||
public double getCompletionPercentage() {
|
||||
if (estimatedTotalCount == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.notifications.NotificationCancellationHelper;
|
||||
@@ -21,6 +22,8 @@ public enum BackupFileIOError {
|
||||
ACCESS_ERROR(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved),
|
||||
FILE_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_file_is_too_large),
|
||||
NOT_ENOUGH_SPACE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_there_is_not_enough_space),
|
||||
VERIFICATION_FAILED(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_could_not_be_verified),
|
||||
ATTACHMENT_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_contains_a_very_large_file),
|
||||
UNKNOWN(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_tap_to_manage_backups);
|
||||
|
||||
private static final short BACKUP_FAILED_ID = 31321;
|
||||
@@ -38,11 +41,12 @@ public enum BackupFileIOError {
|
||||
}
|
||||
|
||||
public void postNotification(@NonNull Context context) {
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), 0);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), PendingIntentFlags.mutable());
|
||||
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.FAILURES)
|
||||
.setSmallIcon(R.drawable.ic_signal_backup)
|
||||
.setContentTitle(context.getString(titleId))
|
||||
.setContentText(context.getString(messageId))
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(context.getString(messageId)))
|
||||
.setContentIntent(pendingIntent)
|
||||
.build();
|
||||
|
||||
@@ -50,22 +54,25 @@ public enum BackupFileIOError {
|
||||
.notify(BACKUP_FAILED_ID, backupFailedNotification);
|
||||
}
|
||||
|
||||
public static void postNotificationForException(@NonNull Context context, @NonNull IOException e, int runAttempt) {
|
||||
public static void postNotificationForException(@NonNull Context context, @NonNull IOException e) {
|
||||
BackupFileIOError error = getFromException(e);
|
||||
|
||||
if (error != null) {
|
||||
error.postNotification(context);
|
||||
}
|
||||
|
||||
if (error == null && runAttempt > 0) {
|
||||
} else {
|
||||
UNKNOWN.postNotification(context);
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable BackupFileIOError getFromException(@NonNull IOException e) {
|
||||
if (e.getMessage() != null) {
|
||||
if (e.getMessage().contains("EFBIG")) return FILE_TOO_LARGE;
|
||||
else if (e.getMessage().contains("ENOSPC")) return NOT_ENOUGH_SPACE;
|
||||
if (e instanceof FullBackupExporter.InvalidBackupStreamException) {
|
||||
return ATTACHMENT_TOO_LARGE;
|
||||
} else if (e.getMessage() != null) {
|
||||
if (e.getMessage().contains("EFBIG")) {
|
||||
return FILE_TOO_LARGE;
|
||||
} else if (e.getMessage().contains("ENOSPC")) {
|
||||
return NOT_ENOUGH_SPACE;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.thoughtcrime.securesms.backup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
class BackupRecordInputStream extends FullBackupBase.BackupStream {
|
||||
|
||||
private final InputStream in;
|
||||
private final Cipher cipher;
|
||||
private final Mac mac;
|
||||
|
||||
private final byte[] cipherKey;
|
||||
|
||||
private final byte[] iv;
|
||||
private int counter;
|
||||
|
||||
BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException {
|
||||
try {
|
||||
this.in = in;
|
||||
|
||||
byte[] headerLengthBytes = new byte[4];
|
||||
StreamUtil.readFully(in, headerLengthBytes);
|
||||
|
||||
int headerLength = Conversions.byteArrayToInt(headerLengthBytes);
|
||||
byte[] headerFrame = new byte[headerLength];
|
||||
StreamUtil.readFully(in, headerFrame);
|
||||
|
||||
BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame);
|
||||
|
||||
if (!frame.hasHeader()) {
|
||||
throw new IOException("Backup stream does not start with header!");
|
||||
}
|
||||
|
||||
BackupProtos.Header header = frame.getHeader();
|
||||
|
||||
this.iv = header.getIv().toByteArray();
|
||||
|
||||
if (iv.length != 16) {
|
||||
throw new IOException("Invalid IV length!");
|
||||
}
|
||||
|
||||
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
|
||||
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
|
||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
||||
|
||||
this.cipherKey = split[0];
|
||||
byte[] macKey = split[1];
|
||||
|
||||
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
||||
this.mac = Mac.getInstance("HmacSHA256");
|
||||
this.mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
||||
|
||||
this.counter = Conversions.byteArrayToInt(iv);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
BackupProtos.BackupFrame readFrame() throws IOException {
|
||||
return readFrame(in);
|
||||
}
|
||||
|
||||
void readAttachmentTo(OutputStream out, int length) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
mac.update(iv);
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
while (length > 0) {
|
||||
int read = in.read(buffer, 0, Math.min(buffer.length, length));
|
||||
if (read == -1) throw new IOException("File ended early!");
|
||||
|
||||
mac.update(buffer, 0, read);
|
||||
|
||||
byte[] plaintext = cipher.update(buffer, 0, read);
|
||||
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.length);
|
||||
}
|
||||
|
||||
length -= read;
|
||||
}
|
||||
|
||||
byte[] plaintext = cipher.doFinal();
|
||||
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.length);
|
||||
}
|
||||
|
||||
out.close();
|
||||
|
||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
||||
byte[] theirMac = new byte[10];
|
||||
|
||||
try {
|
||||
StreamUtil.readFully(in, theirMac);
|
||||
} catch (IOException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw new BadMacException();
|
||||
}
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException {
|
||||
try {
|
||||
byte[] length = new byte[4];
|
||||
StreamUtil.readFully(in, length);
|
||||
|
||||
byte[] frame = new byte[Conversions.byteArrayToInt(length)];
|
||||
StreamUtil.readFully(in, frame);
|
||||
|
||||
byte[] theirMac = new byte[10];
|
||||
System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length);
|
||||
|
||||
mac.update(frame, 0, frame.length - 10);
|
||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
||||
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw new IOException("Bad MAC");
|
||||
}
|
||||
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
|
||||
|
||||
return BackupProtos.BackupFrame.parseFrom(plaintext);
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
static class BadMacException extends IOException {}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import org.greenrobot.eventbus.EventBus
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* Given a backup file, run over it and verify it will decrypt properly when attempting to import it.
|
||||
*/
|
||||
object BackupVerifier {
|
||||
|
||||
private val TAG = Log.tag(BackupVerifier::class.java)
|
||||
|
||||
@JvmStatic
|
||||
fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long): Boolean {
|
||||
val inputStream = BackupRecordInputStream(cipherStream, passphrase)
|
||||
|
||||
var count = 0L
|
||||
var frame: BackupFrame = inputStream.readFrame()
|
||||
|
||||
while (!frame.end) {
|
||||
val verified = when {
|
||||
frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream)
|
||||
frame.hasSticker() -> verifySticker(frame.sticker, inputStream)
|
||||
frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream)
|
||||
else -> true
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return false
|
||||
}
|
||||
|
||||
EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount))
|
||||
|
||||
frame = inputStream.readFrame()
|
||||
}
|
||||
|
||||
cipherStream.close()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, attachment.length)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad attachment: ${attachment.attachmentId}", e)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, sticker.length)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad sticker: ${sticker.rowId}", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean {
|
||||
try {
|
||||
inputStream.readAttachmentTo(NullOutputStream, avatar.length)
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Bad sticker: ${avatar.recipientId}", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private object NullOutputStream : OutputStream() {
|
||||
override fun write(b: Int) = Unit
|
||||
override fun write(b: ByteArray?) = Unit
|
||||
override fun write(b: ByteArray?, off: Int, len: Int) = Unit
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,6 @@ public abstract class FullBackupBase {
|
||||
static class BackupStream {
|
||||
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
|
||||
try {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
|
||||
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-512");
|
||||
byte[] input = passphrase.replace(" ", "").getBytes();
|
||||
byte[] hash = input;
|
||||
@@ -26,7 +24,6 @@ public abstract class FullBackupBase {
|
||||
if (salt != null) digest.update(salt);
|
||||
|
||||
for (int i = 0; i < DIGEST_ROUNDS; i++) {
|
||||
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
|
||||
digest.update(hash);
|
||||
hash = digest.digest(input);
|
||||
}
|
||||
@@ -37,42 +34,4 @@ public abstract class FullBackupBase {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class BackupEvent {
|
||||
public enum Type {
|
||||
PROGRESS,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
private final long count;
|
||||
private final long estimatedTotalCount;
|
||||
|
||||
BackupEvent(Type type, long count, long estimatedTotalCount) {
|
||||
this.type = type;
|
||||
this.count = count;
|
||||
this.estimatedTotalCount = estimatedTotalCount;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public long getCount() {
|
||||
return count;
|
||||
}
|
||||
|
||||
public long getEstimatedTotalCount() {
|
||||
return estimatedTotalCount;
|
||||
}
|
||||
|
||||
public double getCompletionPercentage() {
|
||||
if (estimatedTotalCount == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -19,9 +19,9 @@ import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.CursorUtil;
|
||||
import org.signal.core.util.SetUtil;
|
||||
import org.signal.core.util.Stopwatch;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.kdf.HKDF;
|
||||
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;
|
||||
@@ -50,11 +50,11 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -79,25 +79,25 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
private static final String TAG = Log.tag(FullBackupExporter.class);
|
||||
|
||||
private static final long DATABASE_VERSION_RECORD_COUNT = 1L;
|
||||
private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
|
||||
private static final long DATABASE_VERSION_RECORD_COUNT = 1L;
|
||||
private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
|
||||
private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
|
||||
private static final long FINAL_MESSAGE_COUNT = 1L;
|
||||
private static final long FINAL_MESSAGE_COUNT = 1L;
|
||||
|
||||
private static final Set<String> BLACKLISTED_TABLES = SetUtil.newHashSet(
|
||||
SignedPreKeyDatabase.TABLE_NAME,
|
||||
OneTimePreKeyDatabase.TABLE_NAME,
|
||||
SessionDatabase.TABLE_NAME,
|
||||
SearchDatabase.SMS_FTS_TABLE_NAME,
|
||||
SearchDatabase.MMS_FTS_TABLE_NAME,
|
||||
EmojiSearchDatabase.TABLE_NAME,
|
||||
SenderKeyDatabase.TABLE_NAME,
|
||||
SenderKeySharedDatabase.TABLE_NAME,
|
||||
PendingRetryReceiptDatabase.TABLE_NAME,
|
||||
AvatarPickerDatabase.TABLE_NAME
|
||||
SignedPreKeyDatabase.TABLE_NAME,
|
||||
OneTimePreKeyDatabase.TABLE_NAME,
|
||||
SessionDatabase.TABLE_NAME,
|
||||
SearchDatabase.SMS_FTS_TABLE_NAME,
|
||||
SearchDatabase.MMS_FTS_TABLE_NAME,
|
||||
EmojiSearchDatabase.TABLE_NAME,
|
||||
SenderKeyDatabase.TABLE_NAME,
|
||||
SenderKeySharedDatabase.TABLE_NAME,
|
||||
PendingRetryReceiptDatabase.TABLE_NAME,
|
||||
AvatarPickerDatabase.TABLE_NAME
|
||||
);
|
||||
|
||||
public static void export(@NonNull Context context,
|
||||
public static BackupEvent export(@NonNull Context context,
|
||||
@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull File output,
|
||||
@@ -106,12 +106,12 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
throws IOException
|
||||
{
|
||||
try (OutputStream outputStream = new FileOutputStream(output)) {
|
||||
internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal);
|
||||
return internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal);
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(29)
|
||||
public static void export(@NonNull Context context,
|
||||
public static BackupEvent export(@NonNull Context context,
|
||||
@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull DocumentFile output,
|
||||
@@ -120,7 +120,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
throws IOException
|
||||
{
|
||||
try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) {
|
||||
internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal);
|
||||
return internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,21 +131,21 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
@NonNull String passphrase)
|
||||
throws IOException
|
||||
{
|
||||
internalExport(context, attachmentSecret, input, outputStream, passphrase, false, () -> false);
|
||||
EventBus.getDefault().post(internalExport(context, attachmentSecret, input, outputStream, passphrase, false, () -> false));
|
||||
}
|
||||
|
||||
private static void internalExport(@NonNull Context context,
|
||||
@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull OutputStream fileOutputStream,
|
||||
@NonNull String passphrase,
|
||||
boolean closeOutputStream,
|
||||
@NonNull BackupCancellationSignal cancellationSignal)
|
||||
private static BackupEvent internalExport(@NonNull Context context,
|
||||
@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull SQLiteDatabase input,
|
||||
@NonNull OutputStream fileOutputStream,
|
||||
@NonNull String passphrase,
|
||||
boolean closeOutputStream,
|
||||
@NonNull BackupCancellationSignal cancellationSignal)
|
||||
throws IOException
|
||||
{
|
||||
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
|
||||
int count = 0;
|
||||
long estimatedCountOutside = 0L;
|
||||
long estimatedCountOutside;
|
||||
|
||||
try {
|
||||
outputStream.writeDatabaseVersion(input.getVersion());
|
||||
@@ -211,8 +211,8 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
if (closeOutputStream) {
|
||||
outputStream.close();
|
||||
}
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside));
|
||||
}
|
||||
return new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside);
|
||||
}
|
||||
|
||||
private static long calculateCount(@NonNull Context context, @NonNull SQLiteDatabase input, List<String> tables) {
|
||||
@@ -315,7 +315,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
statement.append('(');
|
||||
|
||||
for (int i=0;i<cursor.getColumnCount();i++) {
|
||||
for (int i = 0; i < cursor.getColumnCount(); i++) {
|
||||
statement.append('?');
|
||||
|
||||
if (cursor.getType(i) == Cursor.FIELD_TYPE_STRING) {
|
||||
@@ -329,10 +329,10 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
} else if (cursor.getType(i) == Cursor.FIELD_TYPE_NULL) {
|
||||
statementBuilder.addParameters(BackupProtos.SqlStatement.SqlParameter.newBuilder().setNullparameter(true));
|
||||
} else {
|
||||
throw new AssertionError("unknown type?" + cursor.getType(i));
|
||||
throw new AssertionError("unknown type?" + cursor.getType(i));
|
||||
}
|
||||
|
||||
if (i < cursor.getColumnCount()-1) {
|
||||
if (i < cursor.getColumnCount() - 1) {
|
||||
statement.append(',');
|
||||
}
|
||||
}
|
||||
@@ -352,80 +352,96 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
|
||||
try {
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE));
|
||||
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull Cursor cursor,
|
||||
@NonNull BackupFrameOutputStream outputStream,
|
||||
int count,
|
||||
long estimatedCount)
|
||||
throws IOException
|
||||
{
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.SIZE));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM));
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(AttachmentDatabase.DATA_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
long fileLength = new File(data).length();
|
||||
long dbLength = size;
|
||||
if (!TextUtils.isEmpty(data)) {
|
||||
long fileLength = new File(data).length();
|
||||
long dbLength = size;
|
||||
|
||||
if (size <= 0 || fileLength != dbLength) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data);
|
||||
Log.w(TAG, "Needed size calculation! Manual: " + size + " File: " + fileLength + " DB: " + dbLength + " ID: " + new AttachmentId(rowId, uniqueId));
|
||||
}
|
||||
if (size <= 0 || fileLength != dbLength) {
|
||||
size = calculateVeryOldStreamLength(attachmentSecret, random, data);
|
||||
Log.w(TAG, "Needed size calculation! Manual: " + size + " File: " + fileLength + " DB: " + dbLength + " ID: " + new AttachmentId(rowId, uniqueId));
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
InputStream inputStream;
|
||||
|
||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
try (InputStream inputStream = openAttachmentStream(attachmentSecret, random, data)) {
|
||||
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
||||
inputStream.close();
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(TAG, "Missing attachment: " + e.getMessage());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
|
||||
try {
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
||||
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret,
|
||||
@NonNull Cursor cursor,
|
||||
@NonNull BackupFrameOutputStream outputStream,
|
||||
int count,
|
||||
long estimatedCount)
|
||||
throws IOException
|
||||
{
|
||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
||||
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
||||
String data = cursor.getString(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_PATH));
|
||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
||||
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
|
||||
outputStream.writeSticker(rowId, inputStream, size);
|
||||
}
|
||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
|
||||
outputStream.writeSticker(rowId, inputStream, size);
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(TAG, "Missing sticker: " + e.getMessage());
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException {
|
||||
private static long calculateVeryOldStreamLength(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) {
|
||||
long result = 0;
|
||||
InputStream inputStream;
|
||||
|
||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||
try (InputStream inputStream = openAttachmentStream(attachmentSecret, random, data)) {
|
||||
int read;
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
int read;
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
|
||||
result += read;
|
||||
while ((read = inputStream.read(buffer, 0, buffer.length)) != -1) {
|
||||
result += read;
|
||||
}
|
||||
} catch (FileNotFoundException e) {
|
||||
Log.w(TAG, "Missing attachment: " + e.getMessage());
|
||||
return 0;
|
||||
} catch (IOException e) {
|
||||
Log.w(TAG, "Failed to determine stream length", e);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static InputStream openAttachmentStream(@NonNull AttachmentSecret attachmentSecret, @Nullable byte[] random, @NonNull String data) throws IOException {
|
||||
if (random != null && random.length == 32) {
|
||||
return ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||
} else {
|
||||
return ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||
}
|
||||
}
|
||||
|
||||
private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream,
|
||||
@NonNull List<String> keysToIncludeInBackup,
|
||||
int count,
|
||||
@@ -479,7 +495,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
|
||||
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
|
||||
}
|
||||
|
||||
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
|
||||
@@ -509,7 +525,7 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
||||
String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE};
|
||||
String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE };
|
||||
String where = MmsDatabase.ID + " = ?";
|
||||
String[] args = new String[] { String.valueOf(mmsId) };
|
||||
|
||||
@@ -528,21 +544,19 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
private final Cipher cipher;
|
||||
private final Mac mac;
|
||||
|
||||
private final byte[] cipherKey;
|
||||
private final byte[] macKey;
|
||||
|
||||
private byte[] iv;
|
||||
private int counter;
|
||||
private final byte[] cipherKey;
|
||||
private final byte[] iv;
|
||||
private int counter;
|
||||
|
||||
private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase) throws IOException {
|
||||
try {
|
||||
byte[] salt = Util.getSecretBytes(32);
|
||||
byte[] key = getBackupKey(passphrase, salt);
|
||||
byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
|
||||
byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64);
|
||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
||||
|
||||
this.cipherKey = split[0];
|
||||
this.macKey = split[1];
|
||||
byte[] macKey = split[1];
|
||||
|
||||
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
||||
this.mac = Mac.getInstance("HmacSHA256");
|
||||
@@ -577,12 +591,17 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
public void write(@NonNull String avatarName, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAvatar(BackupProtos.Avatar.newBuilder()
|
||||
.setRecipientId(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAvatar(BackupProtos.Avatar.newBuilder()
|
||||
.setRecipientId(avatarName)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write avatar to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
}
|
||||
|
||||
if (writeStream(in) != size) {
|
||||
throw new IOException("Size mismatch!");
|
||||
@@ -590,13 +609,18 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
public void write(@NonNull AttachmentId attachmentId, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAttachment(BackupProtos.Attachment.newBuilder()
|
||||
.setRowId(attachmentId.getRowId())
|
||||
.setAttachmentId(attachmentId.getUniqueId())
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setAttachment(BackupProtos.Attachment.newBuilder()
|
||||
.setRowId(attachmentId.getRowId())
|
||||
.setAttachmentId(attachmentId.getUniqueId())
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write " + attachmentId + " to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
}
|
||||
|
||||
if (writeStream(in) != size) {
|
||||
throw new IOException("Size mismatch!");
|
||||
@@ -604,12 +628,17 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
}
|
||||
|
||||
public void writeSticker(long rowId, @NonNull InputStream in, long size) throws IOException {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setSticker(BackupProtos.Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
try {
|
||||
write(outputStream, BackupProtos.BackupFrame.newBuilder()
|
||||
.setSticker(BackupProtos.Sticker.newBuilder()
|
||||
.setRowId(rowId)
|
||||
.setLength(Util.toIntExact(size))
|
||||
.build())
|
||||
.build());
|
||||
} catch (ArithmeticException e) {
|
||||
Log.w(TAG, "Unable to write sticker to backup", e);
|
||||
throw new InvalidBackupStreamException();
|
||||
}
|
||||
|
||||
if (writeStream(in) != size) {
|
||||
throw new IOException("Size mismatch!");
|
||||
@@ -683,17 +712,20 @@ public class FullBackupExporter extends FullBackupBase {
|
||||
|
||||
|
||||
public void close() throws IOException {
|
||||
outputStream.flush();
|
||||
outputStream.close();
|
||||
}
|
||||
}
|
||||
|
||||
public interface PostProcessor {
|
||||
int postProcess(@NonNull Cursor cursor, int count);
|
||||
int postProcess(@NonNull Cursor cursor, int count) throws IOException;
|
||||
}
|
||||
|
||||
public interface BackupCancellationSignal {
|
||||
boolean isCanceled();
|
||||
}
|
||||
|
||||
public static final class BackupCanceledException extends IOException { }
|
||||
public static final class BackupCanceledException extends IOException {}
|
||||
|
||||
public static final class InvalidBackupStreamException extends IOException {}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,8 @@ import androidx.annotation.NonNull;
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.signal.core.util.Conversions;
|
||||
import org.signal.core.util.StreamUtil;
|
||||
import org.signal.core.util.SqlUtil;
|
||||
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;
|
||||
@@ -38,7 +35,6 @@ 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.signal.core.util.SqlUtil;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
@@ -46,24 +42,12 @@ import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@@ -185,7 +169,7 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
|
||||
contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath());
|
||||
contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first);
|
||||
} catch (BadMacException e) {
|
||||
} catch (BackupRecordInputStream.BadMacException e) {
|
||||
Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e);
|
||||
dataFile.delete();
|
||||
contentValues.put(AttachmentDatabase.DATA, (String) null);
|
||||
@@ -301,144 +285,6 @@ public class FullBackupImporter extends FullBackupBase {
|
||||
}
|
||||
}
|
||||
|
||||
private static class BackupRecordInputStream extends BackupStream {
|
||||
|
||||
private final InputStream in;
|
||||
private final Cipher cipher;
|
||||
private final Mac mac;
|
||||
|
||||
private final byte[] cipherKey;
|
||||
private final byte[] macKey;
|
||||
|
||||
private byte[] iv;
|
||||
private int counter;
|
||||
|
||||
private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException {
|
||||
try {
|
||||
this.in = in;
|
||||
|
||||
byte[] headerLengthBytes = new byte[4];
|
||||
StreamUtil.readFully(in, headerLengthBytes);
|
||||
|
||||
int headerLength = Conversions.byteArrayToInt(headerLengthBytes);
|
||||
byte[] headerFrame = new byte[headerLength];
|
||||
StreamUtil.readFully(in, headerFrame);
|
||||
|
||||
BackupFrame frame = BackupFrame.parseFrom(headerFrame);
|
||||
|
||||
if (!frame.hasHeader()) {
|
||||
throw new IOException("Backup stream does not start with header!");
|
||||
}
|
||||
|
||||
BackupProtos.Header header = frame.getHeader();
|
||||
|
||||
this.iv = header.getIv().toByteArray();
|
||||
|
||||
if (iv.length != 16) {
|
||||
throw new IOException("Invalid IV length!");
|
||||
}
|
||||
|
||||
byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null);
|
||||
byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64);
|
||||
byte[][] split = ByteUtil.split(derived, 32, 32);
|
||||
|
||||
this.cipherKey = split[0];
|
||||
this.macKey = split[1];
|
||||
|
||||
this.cipher = Cipher.getInstance("AES/CTR/NoPadding");
|
||||
this.mac = Mac.getInstance("HmacSHA256");
|
||||
this.mac.init(new SecretKeySpec(macKey, "HmacSHA256"));
|
||||
|
||||
this.counter = Conversions.byteArrayToInt(iv);
|
||||
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
BackupFrame readFrame() throws IOException {
|
||||
return readFrame(in);
|
||||
}
|
||||
|
||||
void readAttachmentTo(OutputStream out, int length) throws IOException {
|
||||
try {
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
mac.update(iv);
|
||||
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
while (length > 0) {
|
||||
int read = in.read(buffer, 0, Math.min(buffer.length, length));
|
||||
if (read == -1) throw new IOException("File ended early!");
|
||||
|
||||
mac.update(buffer, 0, read);
|
||||
|
||||
byte[] plaintext = cipher.update(buffer, 0, read);
|
||||
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.length);
|
||||
}
|
||||
|
||||
length -= read;
|
||||
}
|
||||
|
||||
byte[] plaintext = cipher.doFinal();
|
||||
|
||||
if (plaintext != null) {
|
||||
out.write(plaintext, 0, plaintext.length);
|
||||
}
|
||||
|
||||
out.close();
|
||||
|
||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
||||
byte[] theirMac = new byte[10];
|
||||
|
||||
try {
|
||||
StreamUtil.readFully(in, theirMac);
|
||||
} catch (IOException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw new BadMacException();
|
||||
}
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private BackupFrame readFrame(InputStream in) throws IOException {
|
||||
try {
|
||||
byte[] length = new byte[4];
|
||||
StreamUtil.readFully(in, length);
|
||||
|
||||
byte[] frame = new byte[Conversions.byteArrayToInt(length)];
|
||||
StreamUtil.readFully(in, frame);
|
||||
|
||||
byte[] theirMac = new byte[10];
|
||||
System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length);
|
||||
|
||||
mac.update(frame, 0, frame.length - 10);
|
||||
byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10);
|
||||
|
||||
if (!MessageDigest.isEqual(ourMac, theirMac)) {
|
||||
throw new IOException("Bad MAC");
|
||||
}
|
||||
|
||||
Conversions.intToByteArray(iv, 0, counter++);
|
||||
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv));
|
||||
|
||||
byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10);
|
||||
|
||||
return BackupFrame.parseFrom(plaintext);
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static class BadMacException extends IOException {}
|
||||
|
||||
public static class DatabaseDowngradeException extends IOException {
|
||||
DatabaseDowngradeException(int currentVersion, int backupVersion) {
|
||||
super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion);
|
||||
|
||||
@@ -11,14 +11,14 @@ 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
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
|
||||
/**
|
||||
* Activity which houses the gift flow.
|
||||
*/
|
||||
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
|
||||
|
||||
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||
override val stripeRepository: StripeRepository by lazy { StripeRepository(this) }
|
||||
|
||||
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ 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
|
||||
@@ -36,6 +35,7 @@ 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.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
/**
|
||||
@@ -75,7 +75,7 @@ class GiftFlowConfirmationFragment :
|
||||
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
|
||||
private val debouncer = Debouncer(100L)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
RecipientPreference.register(adapter)
|
||||
GiftRowItem.register(adapter)
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.badges.Badges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||
import java.io.IOException
|
||||
import java.util.Currency
|
||||
import java.util.Locale
|
||||
|
||||
@@ -17,6 +25,10 @@ import java.util.Locale
|
||||
*/
|
||||
class GiftFlowRepository {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(GiftFlowRepository::class.java)
|
||||
}
|
||||
|
||||
fun getGiftBadge(): Single<Pair<Long, Badge>> {
|
||||
return Single
|
||||
.fromCallable {
|
||||
@@ -44,4 +56,37 @@ class GiftFlowRepository {
|
||||
.mapValues { (currency, price) -> FiatMoney(price, currency) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the given recipient is a supported target for a gift.
|
||||
*/
|
||||
fun verifyRecipientIsAllowedToReceiveAGift(badgeRecipient: RecipientId): Completable {
|
||||
return Completable.fromAction {
|
||||
Log.d(TAG, "Verifying badge recipient $badgeRecipient", true)
|
||||
val recipient = Recipient.resolved(badgeRecipient)
|
||||
|
||||
if (recipient.isSelf) {
|
||||
Log.d(TAG, "Cannot send a gift to self.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
}
|
||||
|
||||
if (recipient.isGroup || recipient.isDistributionList || recipient.registered != RecipientDatabase.RegisteredState.REGISTERED) {
|
||||
Log.w(TAG, "Invalid badge recipient $badgeRecipient. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientIsInvalid
|
||||
}
|
||||
|
||||
try {
|
||||
val profile = ProfileUtil.retrieveProfileSync(ApplicationDependencies.getApplication(), recipient, SignalServiceProfile.RequestType.PROFILE_AND_CREDENTIAL)
|
||||
if (!profile.profile.capabilities.isGiftBadges) {
|
||||
Log.w(TAG, "Badge recipient does not support gifting. Verification failed.", true)
|
||||
throw DonationError.GiftRecipientVerificationError.SelectedRecipientDoesNotSupportGifts
|
||||
} else {
|
||||
Log.d(TAG, "Badge recipient supports gifting. Verification successful.", true)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to retrieve profile for recipient.", e, true)
|
||||
throw DonationError.GiftRecipientVerificationError.FailedToFetchProfile(e)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,22 @@ import android.view.View
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import org.signal.core.util.DimensionUnit
|
||||
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.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.OneTimeDonationRepository
|
||||
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.components.settings.models.SplashImage
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
@@ -27,16 +32,23 @@ class GiftFlowStartFragment : DSLSettingsFragment(
|
||||
|
||||
private val viewModel: GiftFlowViewModel by viewModels(
|
||||
ownerProducer = { requireActivity() },
|
||||
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
|
||||
factoryProducer = {
|
||||
GiftFlowViewModel.Factory(
|
||||
GiftFlowRepository(),
|
||||
requireListener<DonationPaymentComponent>().stripeRepository,
|
||||
OneTimeDonationRepository(ApplicationDependencies.getDonationsService())
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
CurrencySelection.register(adapter)
|
||||
GiftRowItem.register(adapter)
|
||||
NetworkFailure.register(adapter)
|
||||
IndeterminateLoadingCircle.register(adapter)
|
||||
SplashImage.register(adapter)
|
||||
|
||||
val next = requireView().findViewById<View>(R.id.next)
|
||||
next.setOnClickListener {
|
||||
@@ -58,6 +70,28 @@ class GiftFlowStartFragment : DSLSettingsFragment(
|
||||
|
||||
private fun getConfiguration(state: GiftFlowState): DSLConfiguration {
|
||||
return configure {
|
||||
customPref(
|
||||
SplashImage.Model(
|
||||
R.drawable.ic_gift_chat
|
||||
)
|
||||
)
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.GiftFlowStartFragment__gift_a_badge,
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_Headline)
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(R.string.GiftFlowStartFragment__gift_someone_a_badge, DSLSettingsText.CenterModifier)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
|
||||
customPref(
|
||||
CurrencySelection.Model(
|
||||
selectedCurrency = state.currency,
|
||||
|
||||
@@ -5,8 +5,10 @@ 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.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
@@ -15,10 +17,14 @@ 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.signal.donations.GooglePayPaymentSource
|
||||
import org.signal.donations.StripeApi
|
||||
import org.signal.donations.StripeIntentAccessor
|
||||
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.OneTimeDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.StripeRepository
|
||||
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
|
||||
@@ -33,8 +39,9 @@ import java.util.Currency
|
||||
* Maintains state as a user works their way through the gift flow.
|
||||
*/
|
||||
class GiftFlowViewModel(
|
||||
val repository: GiftFlowRepository,
|
||||
val donationPaymentRepository: DonationPaymentRepository
|
||||
private val giftFlowRepository: GiftFlowRepository,
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private var giftToPurchase: Gift? = null
|
||||
@@ -82,7 +89,7 @@ class GiftFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftPricing().subscribe { giftPrices ->
|
||||
disposables += giftFlowRepository.getGiftPricing().subscribe { giftPrices ->
|
||||
store.update {
|
||||
it.copy(
|
||||
giftPrices = giftPrices,
|
||||
@@ -91,7 +98,7 @@ class GiftFlowViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
disposables += repository.getGiftBadge().subscribeBy(
|
||||
disposables += giftFlowRepository.getGiftBadge().subscribeBy(
|
||||
onSuccess = { (giftLevel, giftBadge) ->
|
||||
store.update {
|
||||
it.copy(
|
||||
@@ -134,12 +141,12 @@ class GiftFlowViewModel(
|
||||
this.giftToPurchase = Gift(giftLevel, giftPrice)
|
||||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.RECIPIENT_VERIFICATION) }
|
||||
disposables += donationPaymentRepository.verifyRecipientIsAllowedToReceiveAGift(giftRecipient)
|
||||
disposables += giftFlowRepository.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)
|
||||
stripeRepository.requestTokenFromGooglePay(giftToPurchase!!.price, label, Gifts.GOOGLE_PAY_REQUEST_CODE)
|
||||
},
|
||||
onError = this::onPaymentFlowError
|
||||
)
|
||||
@@ -155,7 +162,7 @@ class GiftFlowViewModel(
|
||||
|
||||
val recipient = store.state.recipient?.id
|
||||
|
||||
donationPaymentRepository.onActivityResult(
|
||||
stripeRepository.onActivityResult(
|
||||
requestCode, resultCode, data, Gifts.GOOGLE_PAY_REQUEST_CODE,
|
||||
object : GooglePayApi.PaymentRequestCallback {
|
||||
override fun onSuccess(paymentData: PaymentData) {
|
||||
@@ -164,7 +171,14 @@ class GiftFlowViewModel(
|
||||
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.PAYMENT_PIPELINE) }
|
||||
|
||||
donationPaymentRepository.continuePayment(gift.price, paymentData, recipient, store.state.additionalMessage?.toString(), gift.level).subscribeBy(
|
||||
val continuePayment: Single<StripeIntentAccessor> = stripeRepository.continuePayment(gift.price, recipient, gift.level)
|
||||
val intentAndSource: Single<Pair<StripeIntentAccessor, StripeApi.PaymentSource>> = Single.zip(continuePayment, Single.just(GooglePayPaymentSource(paymentData)), ::Pair)
|
||||
|
||||
disposables += intentAndSource.flatMapCompletable { (paymentIntent, paymentSource) ->
|
||||
stripeRepository.confirmPayment(paymentSource, paymentIntent, recipient)
|
||||
.flatMapCompletable { Completable.complete() } // We do not currently handle 3DS for gifts.
|
||||
.andThen(oneTimeDonationRepository.waitForOneTimeRedemption(gift.price, paymentIntent.intentId, recipient, store.state.additionalMessage?.toString(), gift.level))
|
||||
}.subscribeBy(
|
||||
onError = this@GiftFlowViewModel::onPaymentFlowError,
|
||||
onComplete = {
|
||||
store.update { it.copy(stage = GiftFlowState.Stage.READY) }
|
||||
@@ -237,13 +251,15 @@ class GiftFlowViewModel(
|
||||
|
||||
class Factory(
|
||||
private val repository: GiftFlowRepository,
|
||||
private val donationPaymentRepository: DonationPaymentRepository
|
||||
private val stripeRepository: StripeRepository,
|
||||
private val oneTimeDonationRepository: OneTimeDonationRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(
|
||||
GiftFlowViewModel(
|
||||
repository,
|
||||
donationPaymentRepository
|
||||
stripeRepository,
|
||||
oneTimeDonationRepository
|
||||
)
|
||||
) as T
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
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.databinding.SubscriptionPreferenceBinding
|
||||
import org.thoughtcrime.securesms.payments.FiatMoneyUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -19,7 +17,7 @@ import java.util.concurrent.TimeUnit
|
||||
*/
|
||||
object GiftRowItem {
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.subscription_preference))
|
||||
mappingAdapter.registerFactory(Model::class.java, BindingFactory(::ViewHolder, SubscriptionPreferenceBinding::inflate))
|
||||
}
|
||||
|
||||
class Model(val giftBadge: Badge, val price: FiatMoney) : MappingModel<Model> {
|
||||
@@ -28,19 +26,15 @@ object GiftRowItem {
|
||||
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)
|
||||
class ViewHolder(binding: SubscriptionPreferenceBinding) : BindingViewHolder<Model, SubscriptionPreferenceBinding>(binding) {
|
||||
init {
|
||||
binding.root.isSelected = true
|
||||
}
|
||||
|
||||
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)
|
||||
binding.check.visible = false
|
||||
binding.badge.setBadge(model.giftBadge)
|
||||
binding.tagline.setText(R.string.GiftRowItem__send_a_gift_badge)
|
||||
|
||||
val price = FiatMoneyUtil.format(
|
||||
context.resources,
|
||||
@@ -52,7 +46,7 @@ object GiftRowItem {
|
||||
|
||||
val duration = TimeUnit.MILLISECONDS.toDays(model.giftBadge.duration)
|
||||
|
||||
priceView.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
|
||||
binding.title.text = context.resources.getQuantityString(R.plurals.GiftRowItem_s_dot_d_day_duration, duration.toInt(), price, duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ class ViewReceivedGiftViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
store.dispose()
|
||||
}
|
||||
|
||||
fun setChecked(isChecked: Boolean) {
|
||||
|
||||
@@ -38,6 +38,7 @@ class ViewSentGiftViewModel(
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.dispose()
|
||||
store.dispose()
|
||||
}
|
||||
|
||||
class Factory(
|
||||
|
||||
@@ -10,6 +10,9 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||
|
||||
/**
|
||||
* "Hero Image" for displaying an Avatar and badge. Allows the user to see what their profile will look like with a particular badge applied.
|
||||
*/
|
||||
object BadgePreview {
|
||||
|
||||
fun register(mappingAdapter: MappingAdapter) {
|
||||
@@ -19,6 +22,11 @@ object BadgePreview {
|
||||
}
|
||||
|
||||
sealed class BadgeModel<T : BadgeModel<T>> : MappingModel<T> {
|
||||
|
||||
companion object {
|
||||
const val PAYLOAD_BADGE = "badge"
|
||||
}
|
||||
|
||||
abstract val badge: Badge?
|
||||
abstract val recipient: Recipient
|
||||
|
||||
@@ -33,12 +41,20 @@ object BadgePreview {
|
||||
data class GiftedBadgeModel(override val badge: Badge?, override val recipient: Recipient) : BadgeModel<GiftedBadgeModel>()
|
||||
|
||||
override fun areItemsTheSame(newItem: T): Boolean {
|
||||
return badge?.id == newItem.badge?.id && recipient.id == newItem.recipient.id
|
||||
return recipient.id == newItem.recipient.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: T): Boolean {
|
||||
return badge == newItem.badge && recipient.hasSameContent(newItem.recipient)
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: T): Any? {
|
||||
return if (recipient.hasSameContent(newItem.recipient) && badge != newItem.badge) {
|
||||
PAYLOAD_BADGE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
@@ -42,8 +42,13 @@ data class LargeBadge(
|
||||
override fun bind(model: Model) {
|
||||
badge.setBadge(model.largeBadge.badge)
|
||||
|
||||
name.text = model.largeBadge.badge.name
|
||||
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
|
||||
name.text = context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal, model.shortName)
|
||||
description.text = if (model.largeBadge.badge.isSubscription()) {
|
||||
context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal_with_a_monthly, model.shortName)
|
||||
} else {
|
||||
context.getString(R.string.ViewBadgeBottomSheetDialogFragment__s_supports_signal_with_a_donation, model.shortName)
|
||||
}
|
||||
|
||||
description.setLines(model.maxLines)
|
||||
description.maxLines = model.maxLines
|
||||
description.minLines = model.maxLines
|
||||
|
||||
@@ -13,11 +13,11 @@ import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
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.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
|
||||
/**
|
||||
* Fragment which allows user to select one of their badges to be their "Featured" badge.
|
||||
@@ -50,7 +50,7 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
return Material3OnScrollHelper(requireActivity(), scrollShadow)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
Badge.register(adapter) { badge, isSelected, _ ->
|
||||
if (!isSelected) {
|
||||
viewModel.setSelectedBadge(badge)
|
||||
|
||||
@@ -12,7 +12,7 @@ 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.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
@@ -21,7 +21,7 @@ class BecomeASustainerFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: BecomeASustainerViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BecomeASustainerViewModel.Factory(SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
BecomeASustainerViewModel.Factory(MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository) : ViewModel() {
|
||||
class BecomeASustainerViewModel(subscriptionsRepository: MonthlyDonationRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(BecomeASustainerState())
|
||||
|
||||
@@ -37,7 +37,7 @@ class BecomeASustainerViewModel(subscriptionsRepository: SubscriptionsRepository
|
||||
private val TAG = Log.tag(BecomeASustainerViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(private val subscriptionsRepository: SubscriptionsRepository) : ViewModelProvider.Factory {
|
||||
class Factory(private val subscriptionsRepository: MonthlyDonationRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(BecomeASustainerViewModel(subscriptionsRepository))!!
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import org.thoughtcrime.securesms.badges.Badges.displayBadges
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.view.ViewBadgeBottomSheetDialogFragment
|
||||
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.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||
|
||||
/**
|
||||
@@ -31,11 +31,11 @@ class BadgesOverviewFragment : DSLSettingsFragment(
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val viewModel: BadgesOverviewViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), SubscriptionsRepository(ApplicationDependencies.getDonationsService()))
|
||||
BadgesOverviewViewModel.Factory(BadgeRepository(requireContext()), MonthlyDonationRepository(ApplicationDependencies.getDonationsService()))
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
Badge.register(adapter) { badge, _, isFaded ->
|
||||
if (badge.isExpired() || isFaded) {
|
||||
findNavController().safeNavigate(BadgesOverviewFragmentDirections.actionBadgeManageFragmentToExpiredBadgeDialog(badge, null, null))
|
||||
|
||||
@@ -12,7 +12,7 @@ import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.SubscriptionsRepository
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.MonthlyDonationRepository
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.InternetConnectionObserver
|
||||
@@ -23,7 +23,7 @@ private val TAG = Log.tag(BadgesOverviewViewModel::class.java)
|
||||
|
||||
class BadgesOverviewViewModel(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModel() {
|
||||
private val store = Store(BadgesOverviewState())
|
||||
private val eventSubject = PublishSubject.create<BadgesOverviewEvent>()
|
||||
@@ -89,7 +89,7 @@ class BadgesOverviewViewModel(
|
||||
|
||||
class Factory(
|
||||
private val badgeRepository: BadgeRepository,
|
||||
private val subscriptionsRepository: SubscriptionsRepository
|
||||
private val subscriptionsRepository: MonthlyDonationRepository
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(BadgesOverviewViewModel(badgeRepository, subscriptionsRepository)))
|
||||
|
||||
@@ -10,21 +10,20 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeRepository
|
||||
import org.thoughtcrime.securesms.badges.models.Badge
|
||||
import org.thoughtcrime.securesms.badges.models.LargeBadge
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations
|
||||
import org.thoughtcrime.securesms.databinding.ViewBadgeBottomSheetDialogFragmentBinding
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
@@ -44,6 +43,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
textSize = ViewUtil.spToPx(16f).toFloat()
|
||||
}
|
||||
|
||||
private val binding by ViewBinderDelegate(ViewBadgeBottomSheetDialogFragmentBinding::bind)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.view_badge_bottom_sheet_dialog_fragment, container, false)
|
||||
}
|
||||
@@ -51,44 +52,36 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
postponeEnterTransition()
|
||||
|
||||
val pager: ViewPager2 = view.findViewById(R.id.pager)
|
||||
val tabs: TabLayout = view.findViewById(R.id.tab_layout)
|
||||
val action: MaterialButton = view.findViewById(R.id.action)
|
||||
val noSupport: View = view.findViewById(R.id.no_support)
|
||||
|
||||
if (getRecipientId() == Recipient.self().id) {
|
||||
action.visible = false
|
||||
binding.action.visible = false
|
||||
}
|
||||
|
||||
@Suppress("CascadeIf")
|
||||
if (PlayServicesUtil.getPlayServicesStatus(requireContext()) != PlayServicesUtil.PlayServicesStatus.SUCCESS) {
|
||||
noSupport.visible = true
|
||||
action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20)
|
||||
action.setText(R.string.preferences__donate_to_signal)
|
||||
action.setOnClickListener {
|
||||
if (!InAppDonations.hasAtLeastOnePaymentMethodAvailable()) {
|
||||
binding.noSupport.visible = true
|
||||
binding.action.icon = ContextCompat.getDrawable(requireContext(), R.drawable.ic_open_20)
|
||||
binding.action.setText(R.string.preferences__donate_to_signal)
|
||||
binding.action.setOnClickListener {
|
||||
CommunicationActions.openBrowserLink(requireContext(), getString(R.string.donate_url))
|
||||
}
|
||||
} else if (
|
||||
FeatureFlags.donorBadges() &&
|
||||
Recipient.self().badges.none { it.category == Badge.Category.Donor && !it.isBoost() && !it.isExpired() }
|
||||
) {
|
||||
action.setOnClickListener {
|
||||
} else if (Recipient.self().badges.none { it.category == Badge.Category.Donor && !it.isBoost() && !it.isExpired() }) {
|
||||
binding.action.setOnClickListener {
|
||||
startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||
}
|
||||
} else {
|
||||
action.visible = false
|
||||
binding.action.visible = false
|
||||
}
|
||||
|
||||
val adapter = MappingAdapter()
|
||||
|
||||
LargeBadge.register(adapter)
|
||||
pager.adapter = adapter
|
||||
binding.pager.adapter = adapter
|
||||
adapter.submitList(listOf(LargeBadge.EmptyModel()))
|
||||
|
||||
TabLayoutMediator(tabs, pager) { _, _ ->
|
||||
TabLayoutMediator(binding.tabLayout, binding.pager) { _, _ ->
|
||||
}.attach()
|
||||
|
||||
pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
binding.pager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
if (adapter.getModel(position).map { it is LargeBadge.Model }.orElse(false)) {
|
||||
viewModel.onPageSelected(position)
|
||||
@@ -105,7 +98,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
tabs.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
binding.tabLayout.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
binding.singlePageSpace.visible = state.allBadgesVisibleOnProfile.size > 1
|
||||
|
||||
var maxLines = 3
|
||||
state.allBadgesVisibleOnProfile.forEach { badge ->
|
||||
@@ -121,8 +115,8 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
}
|
||||
) {
|
||||
val stateSelectedIndex = state.allBadgesVisibleOnProfile.indexOf(state.selectedBadge)
|
||||
if (state.selectedBadge != null && pager.currentItem != stateSelectedIndex) {
|
||||
pager.currentItem = stateSelectedIndex
|
||||
if (state.selectedBadge != null && binding.pager.currentItem != stateSelectedIndex) {
|
||||
binding.pager.currentItem = stateSelectedIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,10 +137,6 @@ class ViewBadgeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFr
|
||||
recipientId: RecipientId,
|
||||
startBadge: Badge? = null
|
||||
) {
|
||||
if (!FeatureFlags.displayDonorBadges() && recipientId != Recipient.self().id) {
|
||||
return
|
||||
}
|
||||
|
||||
ViewBadgeBottomSheetDialogFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARG_START_BADGE, startBadge)
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
@@ -48,7 +48,7 @@ public class BlockedUsersActivity extends PassphraseRequiredActivity implements
|
||||
BlockedUsersRepository repository = new BlockedUsersRepository(this);
|
||||
BlockedUsersViewModel.Factory factory = new BlockedUsersViewModel.Factory(repository);
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory).get(BlockedUsersViewModel.class);
|
||||
viewModel = new ViewModelProvider(this, factory).get(BlockedUsersViewModel.class);
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
ContactFilterView contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||
|
||||
@@ -9,7 +9,7 @@ import android.view.ViewGroup;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.lifecycle.ViewModelProviders;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.thoughtcrime.securesms.BlockUnblockDialog;
|
||||
@@ -59,7 +59,7 @@ public class BlockedUsersFragment extends Fragment {
|
||||
}
|
||||
});
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(BlockedUsersViewModel.class);
|
||||
viewModel = new ViewModelProvider(requireActivity()).get(BlockedUsersViewModel.class);
|
||||
viewModel.getRecipients().observe(getViewLifecycleOwner(), list -> {
|
||||
if (list.isEmpty()) {
|
||||
empty.setVisibility(View.VISIBLE);
|
||||
|
||||
@@ -69,7 +69,7 @@ class BlockedUsersRepository {
|
||||
|
||||
void unblock(@NonNull RecipientId recipientId, @NonNull Runnable success) {
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
RecipientUtil.unblock(context, Recipient.resolved(recipientId));
|
||||
RecipientUtil.unblock(Recipient.resolved(recipientId));
|
||||
success.run();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.color
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents a set of colors to be applied to the foreground and background of a view.
|
||||
*
|
||||
* Supports mixing color ints and color resource ids.
|
||||
*/
|
||||
@Parcelize
|
||||
data class ViewColorSet(
|
||||
val foreground: ViewColor,
|
||||
val background: ViewColor
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
val PRIMARY = ViewColorSet(
|
||||
foreground = ViewColor.ColorResource(R.color.signal_colorOnPrimary),
|
||||
background = ViewColor.ColorResource(R.color.signal_colorPrimary)
|
||||
)
|
||||
|
||||
fun forCustomColor(@ColorInt customColor: Int): ViewColorSet {
|
||||
return ViewColorSet(
|
||||
foreground = ViewColor.ColorResource(R.color.signal_colorOnCustom),
|
||||
background = ViewColor.ColorValue(customColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
sealed class ViewColor : Parcelable {
|
||||
|
||||
@ColorInt
|
||||
abstract fun resolve(context: Context): Int
|
||||
|
||||
@Parcelize
|
||||
data class ColorValue(@ColorInt val colorInt: Int) : ViewColor() {
|
||||
override fun resolve(context: Context): Int {
|
||||
return colorInt
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ColorResource(@ColorRes val colorRes: Int) : ViewColor() {
|
||||
override fun resolve(context: Context): Int {
|
||||
return ContextCompat.getColor(context, colorRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import android.widget.TextView;
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.graphics.drawable.DrawableCompat;
|
||||
import androidx.lifecycle.Observer;
|
||||
|
||||
import com.airbnb.lottie.LottieAnimationView;
|
||||
@@ -126,6 +127,11 @@ public final class AudioView extends FrameLayout {
|
||||
|
||||
setTint(typedArray.getColor(R.styleable.AudioView_foregroundTintColor, Color.WHITE));
|
||||
|
||||
int backgroundTintColor = typedArray.getColor(R.styleable.AudioView_backgroundTintColor, Color.TRANSPARENT);
|
||||
if (getBackground() != null && backgroundTintColor != Color.TRANSPARENT) {
|
||||
DrawableCompat.setTint(getBackground(), backgroundTintColor);
|
||||
}
|
||||
|
||||
this.waveFormPlayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformPlayedBarsColor, Color.WHITE);
|
||||
this.waveFormUnplayedBarsColor = typedArray.getColor(R.styleable.AudioView_waveformUnplayedBarsColor, Color.WHITE);
|
||||
this.waveFormThumbTint = typedArray.getColor(R.styleable.AudioView_waveformThumbTint, Color.WHITE);
|
||||
|
||||
@@ -64,6 +64,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
|
||||
private @Nullable RecipientContactPhoto recipientContactPhoto;
|
||||
private @NonNull Drawable unknownRecipientDrawable;
|
||||
private @Nullable AvatarColor fallbackPhotoColor;
|
||||
|
||||
public AvatarImageView(Context context) {
|
||||
super(context);
|
||||
@@ -105,6 +106,10 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
this.fallbackPhotoProvider = fallbackPhotoProvider;
|
||||
}
|
||||
|
||||
public void setFallbackPhotoColor(@Nullable AvatarColor fallbackPhotoColor) {
|
||||
this.fallbackPhotoColor = fallbackPhotoColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows self as the actual profile picture.
|
||||
*/
|
||||
@@ -213,7 +218,7 @@ public final class AvatarImageView extends AppCompatImageView {
|
||||
requestManager.clear(this);
|
||||
if (fallbackPhotoProvider != null) {
|
||||
setImageDrawable(fallbackPhotoProvider.getPhotoForRecipientWithoutName()
|
||||
.asDrawable(getContext(), AvatarColor.UNKNOWN, inverted));
|
||||
.asDrawable(getContext(), Util.firstNonNull(fallbackPhotoColor, AvatarColor.UNKNOWN), inverted));
|
||||
} else {
|
||||
setImageDrawable(unknownRecipientDrawable);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.thoughtcrime.securesms.database.MentionUtil.MENTION_STARTER;
|
||||
|
||||
@@ -54,6 +56,8 @@ public class ComposeText extends EmojiEditText {
|
||||
private static final char EMOJI_STARTER = ':';
|
||||
private static final long EMOJI_KEYWORD_DELAY = 1500;
|
||||
|
||||
private static final Pattern TIME_PATTERN = Pattern.compile("^[0-9]{1,2}:[0-9]{1,2}$");
|
||||
|
||||
private CharSequence hint;
|
||||
private SpannableString subHint;
|
||||
private MentionRendererDelegate mentionRendererDelegate;
|
||||
@@ -422,16 +426,42 @@ public class ComposeText extends EmojiEditText {
|
||||
}
|
||||
|
||||
int delimiterSearchIndex = inputCursorPosition - 1;
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != starter && text.charAt(delimiterSearchIndex) != ' ')) {
|
||||
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != starter && !Character.isWhitespace(text.charAt(delimiterSearchIndex)))) {
|
||||
delimiterSearchIndex--;
|
||||
}
|
||||
|
||||
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == starter) {
|
||||
return delimiterSearchIndex + 1;
|
||||
if (couldBeTimeEntry(text, delimiterSearchIndex)) {
|
||||
return -1;
|
||||
} else {
|
||||
return delimiterSearchIndex + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if we think the user may be inputting a time.
|
||||
*/
|
||||
private static boolean couldBeTimeEntry(@NonNull CharSequence text, int startIndex) {
|
||||
if (startIndex <= 0 || startIndex + 1 >= text.length()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
int startOfToken = startIndex;
|
||||
while (startOfToken > 0 && !Character.isWhitespace(text.charAt(startOfToken))) {
|
||||
startOfToken--;
|
||||
}
|
||||
startOfToken++;
|
||||
|
||||
int endOfToken = startIndex;
|
||||
while (endOfToken < text.length() && !Character.isWhitespace(text.charAt(endOfToken))) {
|
||||
endOfToken++;
|
||||
}
|
||||
|
||||
return TIME_PATTERN.matcher(text.subSequence(startOfToken, endOfToken)).find();
|
||||
}
|
||||
|
||||
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
|
||||
|
||||
private static final String TAG = Log.tag(CommitContentListener.class);
|
||||
|
||||
@@ -20,7 +20,9 @@ import androidx.annotation.StringRes;
|
||||
import androidx.core.widget.TextViewCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
public final class ContactFilterView extends FrameLayout {
|
||||
@@ -52,6 +54,8 @@ public final class ContactFilterView extends FrameLayout {
|
||||
this.clearToggle = findViewById(R.id.search_clear);
|
||||
this.toggleContainer = findViewById(R.id.toggle_container);
|
||||
|
||||
EditTextExtensionsKt.setIncognitoKeyboardEnabled(searchText, TextSecurePreferences.isIncognitoKeyboardEnabled(context));
|
||||
|
||||
this.keyboardToggle.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
|
||||
@@ -36,7 +36,7 @@ public final class ConversationScrollToView extends FrameLayout {
|
||||
unreadCount = findViewById(R.id.conversation_scroll_to_count);
|
||||
scrollButton = findViewById(R.id.conversation_scroll_to_button);
|
||||
|
||||
if (attrs != null) {
|
||||
if (attrs != null && !isInEditMode()) {
|
||||
TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.ConversationScrollToView);
|
||||
int srcId = array.getResourceId(R.styleable.ConversationScrollToView_cstv_scroll_button_src, 0);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of displaying a dialog fragment. Will automatically close and nullify the reference
|
||||
* if the bound lifecycle is destroyed, and handles repeat calls to show such that no more than one dialog is
|
||||
* displayed.
|
||||
*/
|
||||
class DialogFragmentDisplayManager(private val builder: () -> DialogFragment) : DefaultLifecycleObserver {
|
||||
|
||||
private var dialogFragment: DialogFragment? = null
|
||||
|
||||
fun show(lifecycleOwner: LifecycleOwner, fragmentManager: FragmentManager, tag: String? = null) {
|
||||
val fragment = dialogFragment ?: builder()
|
||||
if (fragment.dialog?.isShowing != true) {
|
||||
fragment.show(fragmentManager, tag)
|
||||
dialogFragment = fragment
|
||||
lifecycleOwner.lifecycle.addObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
dialogFragment?.dismissAllowingStateLoss()
|
||||
dialogFragment = null
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
owner.lifecycle.removeObserver(this)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ public class DocumentView extends FrameLayout {
|
||||
}
|
||||
|
||||
public void setDocument(final @NonNull Slide documentSlide,
|
||||
final boolean showControls)
|
||||
final boolean showControls,
|
||||
final boolean showSingleLineFilename)
|
||||
{
|
||||
if (showControls && documentSlide.isPendingDownload()) {
|
||||
controlToggle.displayQuick(downloadButton);
|
||||
@@ -106,6 +107,11 @@ public class DocumentView extends FrameLayout {
|
||||
|
||||
this.documentSlide = documentSlide;
|
||||
|
||||
// Android OS filenames are limited to 256 characters, so
|
||||
// we don't need an additional max characters/lines constraint when
|
||||
// [showSingleLineFilename] is false.
|
||||
this.fileName.setSingleLine(showSingleLineFilename);
|
||||
|
||||
this.fileName.setText(OptionalUtil.or(documentSlide.getFileName(),
|
||||
documentSlide.getCaption())
|
||||
.orElse(getContext().getString(R.string.DocumentView_unnamed_file)));
|
||||
@@ -114,6 +120,12 @@ public class DocumentView extends FrameLayout {
|
||||
this.setOnClickListener(new OpenClickedListener(documentSlide));
|
||||
}
|
||||
|
||||
public void setDocument(final @NonNull Slide documentSlide,
|
||||
final boolean showControls)
|
||||
{
|
||||
setDocument(documentSlide, showControls, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setFocusable(boolean focusable) {
|
||||
super.setFocusable(focusable);
|
||||
|
||||
@@ -11,6 +11,7 @@ import android.util.AttributeSet;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.BreakIteratorCompat;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.components.emoji.SimpleEmojiTextView;
|
||||
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import java.util.Iterator;
|
||||
import java.util.Objects;
|
||||
|
||||
public class FromTextView extends SimpleEmojiTextView {
|
||||
|
||||
@@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
|
||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||
@@ -210,6 +211,10 @@ public class InputPanel extends LinearLayout
|
||||
int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius);
|
||||
this.linkPreview.setCorners(cornerRadius, cornerRadius);
|
||||
}
|
||||
|
||||
if (listener != null) {
|
||||
listener.onQuoteChanged(id, author.getId());
|
||||
}
|
||||
}
|
||||
|
||||
public void clearQuote() {
|
||||
@@ -230,6 +235,10 @@ public class InputPanel extends LinearLayout
|
||||
});
|
||||
|
||||
quoteAnimator.start();
|
||||
|
||||
if (listener != null) {
|
||||
listener.onQuoteCleared();
|
||||
}
|
||||
}
|
||||
|
||||
private static ValueAnimator createHeightAnimator(@NonNull View view,
|
||||
@@ -466,7 +475,9 @@ public class InputPanel extends LinearLayout
|
||||
future.addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
fadeInNormalComposeViews();
|
||||
if (voiceNoteDraftView.getDraft() == null) {
|
||||
fadeInNormalComposeViews();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -572,6 +583,8 @@ public class InputPanel extends LinearLayout
|
||||
void onEmojiToggle();
|
||||
void onLinkPreviewCanceled();
|
||||
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
|
||||
void onQuoteChanged(long id, @NonNull RecipientId author);
|
||||
void onQuoteCleared();
|
||||
}
|
||||
|
||||
private static class SlideToCancel {
|
||||
|
||||
@@ -11,7 +11,9 @@ import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.animation.addListener
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.setIncognitoKeyboardEnabled
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
@@ -39,6 +41,8 @@ class Material3SearchToolbar @JvmOverloads constructor(
|
||||
close.setOnClickListener { collapse() }
|
||||
clear.setOnClickListener { input.setText("") }
|
||||
|
||||
input.setIncognitoKeyboardEnabled(TextSecurePreferences.isIncognitoKeyboardEnabled(context))
|
||||
|
||||
input.addTextChangedListener(afterTextChanged = {
|
||||
clear.visible = !it.isNullOrBlank()
|
||||
listener?.onSearchTextChange(it?.toString() ?: "")
|
||||
@@ -80,6 +84,10 @@ class Material3SearchToolbar @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearText() {
|
||||
input.setText("")
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
fun onSearchTextChange(text: String)
|
||||
fun onSearchClosed()
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* A small card with a circular progress indicator in it. Usable in place
|
||||
* of a ProgressDialog, which is deprecated.
|
||||
*
|
||||
* Remember to add this as the last UI element in your XML hierarchy so it'll
|
||||
* draw over top of other elements.
|
||||
*/
|
||||
class ProgressCard @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialCardView(context, attrs) {
|
||||
init {
|
||||
inflate(context, R.layout.progress_card, this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.app.Dialog
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Displays a small progress spinner in a card view, as a non-cancellable dialog fragment.
|
||||
*/
|
||||
class ProgressCardDialogFragment : DialogFragment(R.layout.progress_card_dialog) {
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
isCancelable = false
|
||||
return super.onCreateDialog(savedInstanceState).apply {
|
||||
this.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,12 +305,11 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
missingStoryReaction.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
boolean isTextStory = !attachments.containsMediaSlide() && isStoryReply();
|
||||
|
||||
StoryTextPostModel textPostModel = isStoryReply() ? getStoryTextPost(body) : null;
|
||||
if (!TextUtils.isEmpty(body) || !attachments.containsMediaSlide()) {
|
||||
if (isTextStory && body != null) {
|
||||
if (textPostModel != null) {
|
||||
try {
|
||||
bodyView.setText(getStoryTextPost(body).getText());
|
||||
bodyView.setText(textPostModel.getText());
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Could not parse body of text post.", e);
|
||||
bodyView.setText("");
|
||||
@@ -365,8 +364,8 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
|
||||
mainView.setMinimumHeight(isStoryReply() && originalMissing ? 0 : thumbHeight);
|
||||
thumbnailView.setPadding(0, 0, 0, 0);
|
||||
|
||||
if (!attachments.containsMediaSlide() && isStoryReply()) {
|
||||
StoryTextPostModel model = getStoryTextPost(body);
|
||||
StoryTextPostModel model = isStoryReply() ? getStoryTextPost(body) : null;
|
||||
if (model != null) {
|
||||
attachmentVideoOverlayView.setVisibility(GONE);
|
||||
attachmentContainerView.setVisibility(GONE);
|
||||
thumbnailView.setVisibility(VISIBLE);
|
||||
|
||||
@@ -3,7 +3,7 @@ package org.thoughtcrime.securesms.components;
|
||||
import android.content.Context;
|
||||
import android.content.DialogInterface;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.BuildConfig;
|
||||
@@ -35,14 +35,14 @@ public class RatingManager {
|
||||
}
|
||||
|
||||
private static void showRatingDialog(final Context context) {
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.RatingManager_rate_this_app)
|
||||
.setMessage(R.string.RatingManager_if_you_enjoy_using_this_app_please_take_a_moment)
|
||||
.setPositiveButton(R.string.RatingManager_rate_now, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
TextSecurePreferences.setRatingEnabled(context, false);
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context);
|
||||
new MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.RatingManager_rate_this_app)
|
||||
.setMessage(R.string.RatingManager_if_you_enjoy_using_this_app_please_take_a_moment)
|
||||
.setPositiveButton(R.string.RatingManager_rate_now, new DialogInterface.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(DialogInterface dialog, int which) {
|
||||
TextSecurePreferences.setRatingEnabled(context, false);
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(context);
|
||||
}
|
||||
})
|
||||
.setNegativeButton(R.string.RatingManager_no_thanks, new DialogInterface.OnClickListener() {
|
||||
|
||||
@@ -20,6 +20,8 @@ import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||
import org.thoughtcrime.securesms.util.EditTextExtensionsKt;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
|
||||
public class SearchToolbar extends LinearLayout {
|
||||
|
||||
@@ -57,6 +59,8 @@ public class SearchToolbar extends LinearLayout {
|
||||
SearchView searchView = (SearchView) searchItem.getActionView();
|
||||
EditText searchText = searchView.findViewById(R.id.search_src_text);
|
||||
|
||||
EditTextExtensionsKt.setIncognitoKeyboardEnabled(searchText, TextSecurePreferences.isIncognitoKeyboardEnabled(getContext()));
|
||||
|
||||
searchView.setSubmitButtonEnabled(false);
|
||||
searchView.setMaxWidth(Integer.MAX_VALUE);
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ import android.view.View
|
||||
import android.view.View.OnLongClickListener
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.AppCompatImageButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import java.lang.AssertionError
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
@@ -28,8 +31,10 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
|
||||
private var availableSendTypes: List<MessageSendType> = MessageSendType.getAllAvailable(context, false)
|
||||
private var activeMessageSendType: MessageSendType? = null
|
||||
private var defaultTransportType: MessageSendType.TransportType = MessageSendType.TransportType.SMS
|
||||
private var defaultTransportType: MessageSendType.TransportType = MessageSendType.TransportType.SIGNAL
|
||||
private var defaultSubscriptionId: Int? = null
|
||||
|
||||
lateinit var snackbarContainer: View
|
||||
private var popupContainer: ViewGroup? = null
|
||||
|
||||
init {
|
||||
@@ -96,7 +101,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
fun resetAvailableTransports(isMediaMessage: Boolean) {
|
||||
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
|
||||
activeMessageSendType = null
|
||||
defaultTransportType = MessageSendType.TransportType.SMS
|
||||
defaultTransportType = MessageSendType.TransportType.SIGNAL
|
||||
defaultSubscriptionId = null
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
}
|
||||
@@ -146,10 +151,19 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
if (!isEnabled || availableSendTypes.size == 1) {
|
||||
if (!isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (availableSendTypes.size == 1) {
|
||||
return if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val currentlySelected: MessageSendType = selectedSendType
|
||||
|
||||
val items = availableSendTypes
|
||||
|
||||
@@ -89,7 +89,7 @@ public class ThreadPhotoRailView extends FrameLayout {
|
||||
@Override
|
||||
public void onBindItemViewHolder(ThreadPhotoViewHolder viewHolder, @NonNull Cursor cursor) {
|
||||
ThumbnailView imageView = viewHolder.imageView;
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(getContext(), cursor);
|
||||
MediaDatabase.MediaRecord mediaRecord = MediaDatabase.MediaRecord.from(cursor);
|
||||
Slide slide = MediaUtil.getSlideForAttachment(getContext(), mediaRecord.getAttachment());
|
||||
|
||||
if (slide != null) {
|
||||
|
||||
@@ -18,13 +18,17 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.Px;
|
||||
import androidx.annotation.UiThread;
|
||||
import androidx.appcompat.widget.AppCompatImageView;
|
||||
|
||||
import com.bumptech.glide.RequestBuilder;
|
||||
import com.bumptech.glide.TransitionOptions;
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy;
|
||||
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation;
|
||||
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||
import com.bumptech.glide.load.resource.bitmap.FitCenter;
|
||||
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
|
||||
import com.bumptech.glide.request.RequestListener;
|
||||
import com.bumptech.glide.request.RequestOptions;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
@@ -34,9 +38,11 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequest;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.mms.ImageSlide;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideClickListener;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.stories.StoryTextPostModel;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -62,10 +68,12 @@ public class ThumbnailView extends FrameLayout {
|
||||
private static final int MIN_HEIGHT = 2;
|
||||
private static final int MAX_HEIGHT = 3;
|
||||
|
||||
private ImageView image;
|
||||
private ImageView blurhash;
|
||||
private View playOverlay;
|
||||
private View captionIcon;
|
||||
private final ImageView image;
|
||||
private final ImageView blurhash;
|
||||
private final View playOverlay;
|
||||
private final View captionIcon;
|
||||
private final AppCompatImageView errorImage;
|
||||
|
||||
private OnClickListener parentClickListener;
|
||||
|
||||
private final int[] dimens = new int[2];
|
||||
@@ -97,6 +105,7 @@ public class ThumbnailView extends FrameLayout {
|
||||
this.blurhash = findViewById(R.id.thumbnail_blurhash);
|
||||
this.playOverlay = findViewById(R.id.play_overlay);
|
||||
this.captionIcon = findViewById(R.id.thumbnail_caption_icon);
|
||||
this.errorImage = findViewById(R.id.thumbnail_error);
|
||||
|
||||
super.setOnClickListener(new ThumbnailClickDispatcher());
|
||||
|
||||
@@ -302,6 +311,34 @@ public class ThumbnailView extends FrameLayout {
|
||||
boolean showControls, boolean isPreview,
|
||||
int naturalWidth, int naturalHeight)
|
||||
{
|
||||
if (slide.asAttachment().isPermanentlyFailed()) {
|
||||
this.slide = slide;
|
||||
|
||||
transferControls.ifPresent(c -> c.setVisibility(View.GONE));
|
||||
playOverlay.setVisibility(View.GONE);
|
||||
|
||||
glideRequests.clear(blurhash);
|
||||
blurhash.setImageDrawable(null);
|
||||
|
||||
glideRequests.clear(image);
|
||||
image.setImageDrawable(null);
|
||||
|
||||
int errorImageResource;
|
||||
if (slide instanceof ImageSlide) {
|
||||
errorImageResource = R.drawable.ic_photo_slash_outline_24;
|
||||
} else if (slide instanceof VideoSlide) {
|
||||
errorImageResource = R.drawable.ic_video_slash_outline_24;
|
||||
} else {
|
||||
errorImageResource = R.drawable.ic_error_outline_24;
|
||||
}
|
||||
errorImage.setImageResource(errorImageResource);
|
||||
errorImage.setVisibility(View.VISIBLE);
|
||||
|
||||
return new SettableFuture<>(true);
|
||||
} else {
|
||||
errorImage.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
if (showControls) {
|
||||
getTransferControls().setSlide(slide);
|
||||
getTransferControls().setDownloadClickListener(new DownloadClickDispatcher());
|
||||
@@ -384,13 +421,21 @@ public class ThumbnailView extends FrameLayout {
|
||||
}
|
||||
|
||||
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height) {
|
||||
return setImageResource(glideRequests, uri, width, height, true, null);
|
||||
}
|
||||
|
||||
public ListenableFuture<Boolean> setImageResource(@NonNull GlideRequests glideRequests, @NonNull Uri uri, int width, int height, boolean animate, @Nullable RequestListener<Drawable> listener) {
|
||||
SettableFuture<Boolean> future = new SettableFuture<>();
|
||||
|
||||
if (transferControls.isPresent()) getTransferControls().setVisibility(View.GONE);
|
||||
|
||||
GlideRequest request = glideRequests.load(new DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.transition(withCrossFade());
|
||||
GlideRequest<Drawable> request = glideRequests.load(new DecryptableUri(uri))
|
||||
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||
.listener(listener);
|
||||
|
||||
if (animate) {
|
||||
request = request.transition(withCrossFade());
|
||||
}
|
||||
|
||||
if (width > 0 && height > 0) {
|
||||
request = request.override(width, height);
|
||||
@@ -532,11 +577,13 @@ public class ThumbnailView extends FrameLayout {
|
||||
private class ThumbnailClickDispatcher implements View.OnClickListener {
|
||||
@Override
|
||||
public void onClick(View view) {
|
||||
if (thumbnailClickListener != null &&
|
||||
slide != null &&
|
||||
slide.asAttachment().getUri() != null &&
|
||||
slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE)
|
||||
{
|
||||
boolean validThumbnail = slide != null &&
|
||||
slide.asAttachment().getUri() != null &&
|
||||
slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_DONE;
|
||||
|
||||
boolean permanentFailure = slide != null && slide.asAttachment().isPermanentlyFailed();
|
||||
|
||||
if (thumbnailClickListener != null && (validThumbnail || permanentFailure)) {
|
||||
thumbnailClickListener.onClick(view, slide);
|
||||
} else if (parentClickListener != null) {
|
||||
parentClickListener.onClick(view);
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.annotation.Px;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -58,7 +59,7 @@ public class TooltipPopup extends PopupWindow {
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT);
|
||||
|
||||
this.anchor = anchor;
|
||||
this.anchor = anchor;
|
||||
this.position = getRtlPosition(anchor.getContext(), rawPosition);
|
||||
this.startMargin = startMargin;
|
||||
|
||||
@@ -83,10 +84,10 @@ public class TooltipPopup extends PopupWindow {
|
||||
|
||||
if (backgroundTint == 0) {
|
||||
bubble.getBackground().setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
|
||||
arrow.setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.MULTIPLY);
|
||||
arrow.setColorFilter(ContextCompat.getColor(anchor.getContext(), R.color.tooltip_default_color), PorterDuff.Mode.SRC_IN);
|
||||
} else {
|
||||
bubble.getBackground().setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
|
||||
arrow.setColorFilter(backgroundTint, PorterDuff.Mode.MULTIPLY);
|
||||
arrow.setColorFilter(backgroundTint, PorterDuff.Mode.SRC_IN);
|
||||
}
|
||||
|
||||
if (iconGlideModel != null) {
|
||||
@@ -148,8 +149,10 @@ public class TooltipPopup extends PopupWindow {
|
||||
switch (position) {
|
||||
case POSITION_ABOVE:
|
||||
xoffset += startMargin;
|
||||
xoffset -= DimensionUnit.DP.toPixels(20);
|
||||
case POSITION_BELOW:
|
||||
xoffset += startMargin;
|
||||
xoffset -= DimensionUnit.DP.toPixels(20);
|
||||
break;
|
||||
case POSITION_LEFT:
|
||||
xoffset += startMargin;
|
||||
|
||||
@@ -183,15 +183,20 @@ public final class TransferControlView extends FrameLayout {
|
||||
}
|
||||
|
||||
private int getTransferState(@NonNull List<Slide> slides) {
|
||||
int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE;
|
||||
int transferState = AttachmentDatabase.TRANSFER_PROGRESS_DONE;
|
||||
boolean allFailed = true;
|
||||
|
||||
for (Slide slide : slides) {
|
||||
if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
|
||||
transferState = slide.getTransferState();
|
||||
} else {
|
||||
transferState = Math.max(transferState, slide.getTransferState());
|
||||
if (slide.getTransferState() != AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE) {
|
||||
allFailed = false;
|
||||
if (slide.getTransferState() == AttachmentDatabase.TRANSFER_PROGRESS_PENDING && transferState == AttachmentDatabase.TRANSFER_PROGRESS_DONE) {
|
||||
transferState = slide.getTransferState();
|
||||
} else {
|
||||
transferState = Math.max(transferState, slide.getTransferState());
|
||||
}
|
||||
}
|
||||
}
|
||||
return transferState;
|
||||
return allFailed ? AttachmentDatabase.TRANSFER_PROGRESS_PERMANENT_FAILURE : transferState;
|
||||
}
|
||||
|
||||
private String getDownloadText(@NonNull List<Slide> slides) {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.components
|
||||
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* ViewBinderDelegate which enforces the "best practices" for maintaining a reference to a view binding given by
|
||||
* Android official documentation.
|
||||
*/
|
||||
open class ViewBinderDelegate<T : ViewBinding>(
|
||||
private val bindingFactory: (View) -> T,
|
||||
private val onBindingWillBeDestroyed: (T) -> Unit = {}
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
private var binding: T? = null
|
||||
|
||||
operator fun getValue(thisRef: Fragment, property: KProperty<*>): T {
|
||||
if (binding == null) {
|
||||
if (!thisRef.viewLifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) {
|
||||
error("Invalid state to create a binding.")
|
||||
}
|
||||
|
||||
thisRef.viewLifecycleOwner.lifecycle.addObserver(this@ViewBinderDelegate)
|
||||
binding = bindingFactory(thisRef.requireView())
|
||||
}
|
||||
|
||||
return binding!!
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
if (binding != null) {
|
||||
onBindingWillBeDestroyed(binding!!)
|
||||
}
|
||||
|
||||
binding = null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user