mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-15 07:28:30 +00:00
Compare commits
1534 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5537039e46 | ||
|
|
0a40432ed4 | ||
|
|
73e46053f0 | ||
|
|
a835e5d143 | ||
|
|
073d5dfe8c | ||
|
|
bfba60b6b6 | ||
|
|
84d9e1d28e | ||
|
|
a14c61c370 | ||
|
|
0db4630f58 | ||
|
|
6d2e51def6 | ||
|
|
091eb0aa2b | ||
|
|
59f05e0815 | ||
|
|
a513e93d18 | ||
|
|
91f6cff4df | ||
|
|
ec3ec969eb | ||
|
|
54d0df9a05 | ||
|
|
320669c54e | ||
|
|
bf491c25f7 | ||
|
|
1e153e129c | ||
|
|
b546d661ba | ||
|
|
6a1a657451 | ||
|
|
ece087eaae | ||
|
|
a04590b658 | ||
|
|
eb6a14e686 | ||
|
|
c7bb0eadc2 | ||
|
|
d70fe8f2cd | ||
|
|
0fd8f73cca | ||
|
|
b9fc36be5a | ||
|
|
4de27482bb | ||
|
|
ba347301cf | ||
|
|
296a113c65 | ||
|
|
43fe789807 | ||
|
|
98dfd5bfbf | ||
|
|
f387785a46 | ||
|
|
7b3d8d01ae | ||
|
|
1712442560 | ||
|
|
e4ddedcc48 | ||
|
|
14503b952a | ||
|
|
5cb3e1cd02 | ||
|
|
7959343661 | ||
|
|
52062679d4 | ||
|
|
9e8350e8c2 | ||
|
|
495c91ba86 | ||
|
|
92b9fda6c7 | ||
|
|
fecfd7cd78 | ||
|
|
6cd6073bc7 | ||
|
|
f149c0adb9 | ||
|
|
3708cc5583 | ||
|
|
4dd8e81db7 | ||
|
|
06b414f4ef | ||
|
|
d471647e12 | ||
|
|
dd3bad858d | ||
|
|
0fe6538ce4 | ||
|
|
1e2f7f0775 | ||
|
|
055b4691d7 | ||
|
|
ebdfa88882 | ||
|
|
d79c4775b6 | ||
|
|
a13599ae2a | ||
|
|
96b2051400 | ||
|
|
ad6d1a2e8d | ||
|
|
eada1e96ee | ||
|
|
91fbc236ce | ||
|
|
202f20893c | ||
|
|
f1d204b834 | ||
|
|
73e19209ff | ||
|
|
835bf3998f | ||
|
|
d83ef56ab1 | ||
|
|
a84a9c5381 | ||
|
|
c6f29fc950 | ||
|
|
4d9dc42868 | ||
|
|
32b66643c5 | ||
|
|
3850c9c89d | ||
|
|
60ae883df6 | ||
|
|
a7e3bdc892 | ||
|
|
9b60bd9a4b | ||
|
|
e9d98b7d39 | ||
|
|
cb0e7ade14 | ||
|
|
268f5c807d | ||
|
|
f6003023bf | ||
|
|
3f160f256a | ||
|
|
9846517075 | ||
|
|
0f1cc03dc0 | ||
|
|
0e5031ab45 | ||
|
|
0e4926b5ec | ||
|
|
a25e7c6d3e | ||
|
|
4081ac2a83 | ||
|
|
98a528f595 | ||
|
|
680325b5ee | ||
|
|
16668574a9 | ||
|
|
0d8f6de4c1 | ||
|
|
4c0a98d526 | ||
|
|
10f78d5daa | ||
|
|
3ce5a7da67 | ||
|
|
4d47b9c594 | ||
|
|
9f6eb142d2 | ||
|
|
0e08b4ee26 | ||
|
|
9b85907918 | ||
|
|
6463dca2c6 | ||
|
|
498b7fee69 | ||
|
|
3478e13d38 | ||
|
|
5f0d37739a | ||
|
|
c5b4f44ab8 | ||
|
|
819c9f61dc | ||
|
|
4f167feaf5 | ||
|
|
de558bc87c | ||
|
|
4a5a65ff6c | ||
|
|
c56e63d62f | ||
|
|
8cd9a3cabe | ||
|
|
3a8c324c12 | ||
|
|
ff882edeae | ||
|
|
fb0aa55cbb | ||
|
|
51015dc898 | ||
|
|
4af40e7861 | ||
|
|
24fcc0c3b0 | ||
|
|
993fc24dd3 | ||
|
|
fddc6bcd5f | ||
|
|
558051086e | ||
|
|
2c187bc55d | ||
|
|
e947979169 | ||
|
|
08f1ddb212 | ||
|
|
4c318d8d82 | ||
|
|
3e6ebfabb0 | ||
|
|
55f4692d99 | ||
|
|
ebe82cf3e6 | ||
|
|
21a8434e4d | ||
|
|
4990778a97 | ||
|
|
303e5c7996 | ||
|
|
599caee229 | ||
|
|
e6f28c6cdd | ||
|
|
fd3b0ee375 | ||
|
|
bd11ed9f17 | ||
|
|
a6a185004d | ||
|
|
3cc556d803 | ||
|
|
c3f9984346 | ||
|
|
10df4ee0d1 | ||
|
|
c03a183904 | ||
|
|
a2893fbec7 | ||
|
|
19cbace33d | ||
|
|
8a78481cca | ||
|
|
e1fd254d15 | ||
|
|
019219f1e1 | ||
|
|
ad3c04cb52 | ||
|
|
61f9dc7498 | ||
|
|
4deb16a37a | ||
|
|
4129151bd2 | ||
|
|
10cf431537 | ||
|
|
011dd2d973 | ||
|
|
c85c4c5020 | ||
|
|
5f1439df00 | ||
|
|
e76bec63a3 | ||
|
|
fc2b67aa0f | ||
|
|
bcd0360dd0 | ||
|
|
04bf2cd0c2 | ||
|
|
aba51da932 | ||
|
|
f8520d83be | ||
|
|
69003dfbe2 | ||
|
|
380b377ed8 | ||
|
|
4c5db983e3 | ||
|
|
48c887ac03 | ||
|
|
f207a82d2f | ||
|
|
56f6888d49 | ||
|
|
66ece479f6 | ||
|
|
c1cc2b064c | ||
|
|
98980b8192 | ||
|
|
79ec76f11f | ||
|
|
45a1c5c369 | ||
|
|
2dc41f319c | ||
|
|
2cdb1b8300 | ||
|
|
e846b4e20a | ||
|
|
961057f620 | ||
|
|
e686a09ce4 | ||
|
|
fc8cf2957f | ||
|
|
0bef37bfc1 | ||
|
|
1618141342 | ||
|
|
d7fb05f596 | ||
|
|
2eb15cc8e3 | ||
|
|
424a0233c2 | ||
|
|
40cf87307a | ||
|
|
643206b946 | ||
|
|
cc95041519 | ||
|
|
45b498f62f | ||
|
|
9e6d78ba5f | ||
|
|
95eba78d9c | ||
|
|
5d9f00b268 | ||
|
|
6a01388e82 | ||
|
|
2ef6f78d39 | ||
|
|
a754c39599 | ||
|
|
14622cd06c | ||
|
|
3132cd1198 | ||
|
|
94c35d86e2 | ||
|
|
3c2c6d782a | ||
|
|
1764b21214 | ||
|
|
260e572071 | ||
|
|
54251a27a8 | ||
|
|
88a8430c31 | ||
|
|
678b653873 | ||
|
|
21592ca5c0 | ||
|
|
1bca2f06bd | ||
|
|
9f166105a6 | ||
|
|
ea08b59e6b | ||
|
|
9aca0af22c | ||
|
|
591d8c3d1a | ||
|
|
22b73494a7 | ||
|
|
9bb80077c6 | ||
|
|
646f41663f | ||
|
|
63cca2de66 | ||
|
|
16361ac489 | ||
|
|
e8f39e8f71 | ||
|
|
7945b3c971 | ||
|
|
e5d196c642 | ||
|
|
979f87db78 | ||
|
|
b70b4fac91 | ||
|
|
031d7b9cb0 | ||
|
|
c68859c606 | ||
|
|
23804046c6 | ||
|
|
7b13550086 | ||
|
|
7949996c5c | ||
|
|
b190f9495a | ||
|
|
b4c0635a63 | ||
|
|
21bd8a308b | ||
|
|
800405fc3e | ||
|
|
bf18db354c | ||
|
|
e0b89bedd4 | ||
|
|
504b7ad5b3 | ||
|
|
0558808370 | ||
|
|
cff3840c51 | ||
|
|
a46fc96ff1 | ||
|
|
77be721f5a | ||
|
|
023b181917 | ||
|
|
311ef0d65b | ||
|
|
74314e08ac | ||
|
|
81df9fcddb | ||
|
|
ff64c2a911 | ||
|
|
8a9605ade8 | ||
|
|
7a449a971f | ||
|
|
258951dea8 | ||
|
|
cdff0a61f2 | ||
|
|
2200af9c31 | ||
|
|
dfb913cb98 | ||
|
|
9ee10512fb | ||
|
|
81c10a1eae | ||
|
|
3e8b5ca91d | ||
|
|
ba0b0cdefa | ||
|
|
f00ee0a226 | ||
|
|
bd4a69eddc | ||
|
|
8c95b37826 | ||
|
|
133d3145d1 | ||
|
|
4a0db31103 | ||
|
|
ce85bb1575 | ||
|
|
eee4ff3f87 | ||
|
|
f6356c9720 | ||
|
|
42d2d415d6 | ||
|
|
683247bf98 | ||
|
|
d7404cf32f | ||
|
|
ec1f771364 | ||
|
|
95ac9628fb | ||
|
|
ba68d795af | ||
|
|
245f7d3e03 | ||
|
|
972ce41689 | ||
|
|
be12a17ff7 | ||
|
|
0c615e2fc2 | ||
|
|
6829257a83 | ||
|
|
b7b7a04fad | ||
|
|
50084f8f73 | ||
|
|
04e8235cfc | ||
|
|
0df3096241 | ||
|
|
29f22d515a | ||
|
|
9931496b0f | ||
|
|
950363a4e9 | ||
|
|
3469e8d0e0 | ||
|
|
586339575f | ||
|
|
807a0e02a2 | ||
|
|
afb2b1a1a2 | ||
|
|
a8946961d5 | ||
|
|
026aaac451 | ||
|
|
159f319d77 | ||
|
|
cf00995b6f | ||
|
|
7c60c32918 | ||
|
|
fd1d2ec8fc | ||
|
|
a11c40e4fe | ||
|
|
1eb2f51398 | ||
|
|
13ed122c3e | ||
|
|
fa02ee1d3d | ||
|
|
4908e39308 | ||
|
|
ad001d585e | ||
|
|
3fd5e55363 | ||
|
|
ebc1bc3f7f | ||
|
|
c51e13fd30 | ||
|
|
fd37613f2f | ||
|
|
eb921f3103 | ||
|
|
d5b6c47670 | ||
|
|
a4494b58f0 | ||
|
|
b0c68b12ed | ||
|
|
b47e5f2fa9 | ||
|
|
bba1315906 | ||
|
|
3e2ecdaaa9 | ||
|
|
fb8e81cf50 | ||
|
|
52a5fb8ea2 | ||
|
|
b2f3867b0b | ||
|
|
45ca3bd7cf | ||
|
|
74b7057608 | ||
|
|
3a060c7a79 | ||
|
|
de426d22bf | ||
|
|
14549fd401 | ||
|
|
1ff16a2c18 | ||
|
|
0174af7b9b | ||
|
|
e7f1d3fc1a | ||
|
|
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 | ||
|
|
b9879e7210 | ||
|
|
ea76ce9b87 | ||
|
|
4b6ff55779 | ||
|
|
718eedcb34 | ||
|
|
999314255c | ||
|
|
886c4b64f2 | ||
|
|
887221fccf | ||
|
|
d4c633a0f2 | ||
|
|
0c7a8a63b5 | ||
|
|
1b053a2613 | ||
|
|
539cd4059d | ||
|
|
c21b0cd145 | ||
|
|
0a2696113c | ||
|
|
710bb386e2 | ||
|
|
2495781055 | ||
|
|
f9b29cd044 | ||
|
|
a0cc2ff90a | ||
|
|
b002235ef7 | ||
|
|
120dda6e68 | ||
|
|
907abf72d3 | ||
|
|
18eac51576 | ||
|
|
caf1329005 | ||
|
|
5f7b07147f | ||
|
|
d7d923c820 | ||
|
|
440d041402 | ||
|
|
11211ee205 | ||
|
|
692006dcd8 | ||
|
|
c4632dc4a3 | ||
|
|
a42c3d7ce8 | ||
|
|
370c2b941c | ||
|
|
8be7fa8655 | ||
|
|
c2b5407911 | ||
|
|
dc04c8ed98 | ||
|
|
c7cd261641 | ||
|
|
19af68a27c | ||
|
|
ba7319e215 | ||
|
|
92201dcd90 | ||
|
|
855d74bbbf | ||
|
|
201f314cfb | ||
|
|
2eef2e1636 | ||
|
|
f05f9287c1 | ||
|
|
49cc962bde | ||
|
|
d0420ba51d | ||
|
|
0e7cffedc9 | ||
|
|
22688789d2 | ||
|
|
4eb2f16ef1 | ||
|
|
ef950bdbb5 | ||
|
|
cb9a219c4b | ||
|
|
9cd1971329 | ||
|
|
a51754e207 | ||
|
|
df3399bde5 | ||
|
|
5140353722 | ||
|
|
26bb52fd60 | ||
|
|
f50bf3e9c2 | ||
|
|
8f12b2041a | ||
|
|
2674fd2df4 | ||
|
|
39d07c0081 | ||
|
|
1eb253562b | ||
|
|
bc7908a4a5 | ||
|
|
a52b64281c | ||
|
|
e3e9f90094 | ||
|
|
a7a5f2e8c6 | ||
|
|
87cb2d6bf8 | ||
|
|
3c78d8619a | ||
|
|
60e9763f7a | ||
|
|
ab897953bf | ||
|
|
d2c2952ccf | ||
|
|
36c882e318 | ||
|
|
18106c1eab | ||
|
|
9f4d8ac12c | ||
|
|
8cb4034c80 | ||
|
|
ad0acc640b | ||
|
|
c907a01077 | ||
|
|
053b0eabde | ||
|
|
5b7ac84e7c | ||
|
|
fee3af42af | ||
|
|
eaa2d58518 | ||
|
|
6c42ded2b1 | ||
|
|
cb7b2d90d5 | ||
|
|
d40be0abf8 | ||
|
|
d6cc4acf5c | ||
|
|
fa2d3e93ae | ||
|
|
7511a9ae8c | ||
|
|
b20658c829 | ||
|
|
09b92a6559 | ||
|
|
b0d75a8a5a | ||
|
|
234f4b4b41 | ||
|
|
3f59425579 | ||
|
|
a50597445a | ||
|
|
a49e781c8d | ||
|
|
570b143582 | ||
|
|
e6829a1b7a | ||
|
|
14f9a3c155 | ||
|
|
b32fe003b2 | ||
|
|
c77718f4c7 | ||
|
|
a50e49e4e6 | ||
|
|
ffd60af3ff | ||
|
|
d62ff6ca06 | ||
|
|
277cfe2d6f | ||
|
|
9b669009df | ||
|
|
9f069bea7b | ||
|
|
c0f00eff25 | ||
|
|
b183a38f3c | ||
|
|
d64aa3bc43 | ||
|
|
28e10dbb43 | ||
|
|
36b1f2816c | ||
|
|
931693f5fa | ||
|
|
9bade7ed4b | ||
|
|
1d6b62d8ca | ||
|
|
b9a225f6c6 | ||
|
|
c8612d5502 | ||
|
|
837f86bdd3 | ||
|
|
6801b5a1a3 | ||
|
|
c9b6287702 | ||
|
|
6cce9ed00f | ||
|
|
cc1a65952b | ||
|
|
0b44935ae2 | ||
|
|
fe6058e0df | ||
|
|
d159a0482a | ||
|
|
b046eca0fb | ||
|
|
c27ca9ad52 | ||
|
|
0f2afa814d | ||
|
|
561c1a883f | ||
|
|
0e8a598985 | ||
|
|
6bd8bc08d8 | ||
|
|
d49c8d5184 | ||
|
|
bcd2763c34 | ||
|
|
b696a0f758 | ||
|
|
c5f4a9c89e | ||
|
|
8767f775e9 | ||
|
|
88b895f5ea | ||
|
|
e024541b8a | ||
|
|
e69d944f11 | ||
|
|
359a39ddaf | ||
|
|
b78633f9a7 | ||
|
|
aa75f1f8a7 | ||
|
|
eb18c073c6 | ||
|
|
3c09655949 | ||
|
|
17b00734ac | ||
|
|
00d5724cec | ||
|
|
4c5a88c6ca | ||
|
|
2e8ebe8b74 | ||
|
|
caab91cdc3 | ||
|
|
9c914ab715 | ||
|
|
819f7a170f | ||
|
|
2f17963b2b | ||
|
|
15111b2792 | ||
|
|
ecbc2d30ca | ||
|
|
34379b8d3a | ||
|
|
b18542a839 | ||
|
|
c4bef8099f | ||
|
|
b223ebe95e | ||
|
|
02ea5ac806 | ||
|
|
e03b54ac0f | ||
|
|
9daa57675d | ||
|
|
e113973358 | ||
|
|
a845a020d6 | ||
|
|
041bde3fd9 | ||
|
|
5927ba9843 | ||
|
|
2e7e165f8a | ||
|
|
4bed90fa37 | ||
|
|
408a6f662d | ||
|
|
c9e1607987 | ||
|
|
f9c0156757 | ||
|
|
43f4bc5abe | ||
|
|
0ea6ddfe80 | ||
|
|
e9cff68e0d | ||
|
|
64b78117c1 | ||
|
|
c1ed8bc37b | ||
|
|
93d370146e | ||
|
|
96539d70df | ||
|
|
07570bbfec | ||
|
|
71a54ae278 | ||
|
|
2d29298ec4 | ||
|
|
42d2799264 | ||
|
|
c1f3e6351c | ||
|
|
40386c910c | ||
|
|
c95fd7cf0c | ||
|
|
453affbe28 | ||
|
|
02b8b4a295 | ||
|
|
870d024cbf | ||
|
|
05bcfcc43f | ||
|
|
efb82369b6 | ||
|
|
088ce0077b | ||
|
|
c169dd308d | ||
|
|
631958e1a6 | ||
|
|
7a0f4fafe2 | ||
|
|
b0dc7fe6df | ||
|
|
524adcb6a4 | ||
|
|
748dbc2ba5 | ||
|
|
e80df64698 | ||
|
|
65965e8ac5 | ||
|
|
60e366e98a | ||
|
|
1a80cb7c42 | ||
|
|
a20c2ec63f | ||
|
|
4656cf4bef | ||
|
|
d01df9f053 | ||
|
|
5af9872806 | ||
|
|
3beb730edb | ||
|
|
6d4dadea48 | ||
|
|
f08521ab55 | ||
|
|
04cf8676cc | ||
|
|
d17896ea09 | ||
|
|
7dfebdca32 | ||
|
|
5b781c45f3 | ||
|
|
c906abdb37 | ||
|
|
78d4d9a3dd | ||
|
|
3eac397263 | ||
|
|
32312da384 | ||
|
|
8f85b58612 | ||
|
|
6aa4706e9b | ||
|
|
adbdb97a28 | ||
|
|
a51dfa1470 | ||
|
|
36ccf9ca54 | ||
|
|
2270dfaf21 | ||
|
|
bd5907ea04 | ||
|
|
6d24c342d2 | ||
|
|
4a3fe771d1 | ||
|
|
ed063b4b95 | ||
|
|
370640eaef | ||
|
|
2a5d385152 | ||
|
|
be2ed8989f | ||
|
|
e413ee4ed9 | ||
|
|
3913166461 | ||
|
|
314ef3452f | ||
|
|
e412cac419 | ||
|
|
f3873c8a7c | ||
|
|
f8d459829e | ||
|
|
9d8e9a3a14 | ||
|
|
abb4f33299 | ||
|
|
a1d444fc19 | ||
|
|
a3802d0af0 | ||
|
|
f441b3d0f1 | ||
|
|
99f1c9fd65 | ||
|
|
041a019439 | ||
|
|
7a34c6ee80 | ||
|
|
50701dd292 | ||
|
|
3336d92cb1 | ||
|
|
66886dfd7b | ||
|
|
358d9ca58c | ||
|
|
85ce85de07 | ||
|
|
cce0a5e820 | ||
|
|
0318c4f080 | ||
|
|
39288dbcbf | ||
|
|
daab296172 | ||
|
|
a44c3c5c2f | ||
|
|
089a3d386f | ||
|
|
f523529338 | ||
|
|
b5a99a6b3f | ||
|
|
159d0109b9 | ||
|
|
4eddeb74c5 | ||
|
|
28de1f5c3d | ||
|
|
85f38bdea8 | ||
|
|
12a7f36bec | ||
|
|
7b805e4041 | ||
|
|
fc55b5d1ea | ||
|
|
1b58164bf3 | ||
|
|
42c32adf8c | ||
|
|
53663b5ebd | ||
|
|
a87fe78c33 | ||
|
|
3e3ccd4b96 | ||
|
|
0e9344c8e3 | ||
|
|
d562ba090e | ||
|
|
9c665d3a71 | ||
|
|
f2e919f39f | ||
|
|
19080a8a5e | ||
|
|
61ce39b5b6 | ||
|
|
c64be82710 | ||
|
|
7bd34d2b99 | ||
|
|
4215b0391d | ||
|
|
96ea4c0cc2 | ||
|
|
1129ca28fb | ||
|
|
ba6e1b5dd5 | ||
|
|
ed25be2e23 | ||
|
|
7a0bd3315b | ||
|
|
8b806a8ac5 | ||
|
|
0ac5782f1f | ||
|
|
e10c20ffd7 | ||
|
|
86227fbd67 | ||
|
|
1cfa5c31f2 | ||
|
|
521bd2cce4 | ||
|
|
858c7a7f2e | ||
|
|
89a6730efe | ||
|
|
9bc25132c3 | ||
|
|
ebc556801e | ||
|
|
6b745ba58a | ||
|
|
6ddb5b983f | ||
|
|
8efd07b3e2 | ||
|
|
e85adad2b4 | ||
|
|
678a6f86ab | ||
|
|
9dc061e64f | ||
|
|
2fed3f7e90 | ||
|
|
af362736de | ||
|
|
d39a4b14e7 | ||
|
|
6a385c7a22 | ||
|
|
2c3d8337c3 | ||
|
|
28feba6a6c | ||
|
|
6ec7834046 | ||
|
|
ee4f3abf22 | ||
|
|
dc66583ef1 | ||
|
|
d30714bfd4 | ||
|
|
d04d2f7e93 | ||
|
|
1328aab939 | ||
|
|
2a9d2cf580 | ||
|
|
a316650aee | ||
|
|
4d1e8b8f75 | ||
|
|
9c7a5e3cc8 | ||
|
|
2022dae37a | ||
|
|
05b7055678 | ||
|
|
53c60e1f6d | ||
|
|
cd8fa58d7e | ||
|
|
c2ffc8332d | ||
|
|
343a49fa26 | ||
|
|
2c700c7e0e | ||
|
|
105d0c778c | ||
|
|
d8bf2392ae | ||
|
|
4585b439d5 | ||
|
|
587aa49db8 | ||
|
|
1ef576f6f8 | ||
|
|
d070ebcd2f | ||
|
|
2c779e700d | ||
|
|
feadde8737 | ||
|
|
64dca6f60b | ||
|
|
4c4cfe917d | ||
|
|
852989ce48 | ||
|
|
3cecd503ab | ||
|
|
d8b97d8f87 | ||
|
|
611950a589 | ||
|
|
73be74dac1 | ||
|
|
94fc7ad3c0 | ||
|
|
290fbbb9ee | ||
|
|
c0735c8119 | ||
|
|
8f5fc83529 | ||
|
|
ac2cbba067 | ||
|
|
1bcfbaf16e | ||
|
|
c950c2bdd2 | ||
|
|
38cecf68b5 | ||
|
|
f932ed6c6a | ||
|
|
0209db4531 | ||
|
|
2e9f43cf94 | ||
|
|
897e176f0d | ||
|
|
fcb4c627e4 | ||
|
|
c85076138a | ||
|
|
8877603e13 | ||
|
|
ae6ca49e4e | ||
|
|
2620a8fc51 | ||
|
|
008f153b66 | ||
|
|
539a0182e0 | ||
|
|
ff64f7368b | ||
|
|
211361684d | ||
|
|
268b00bbf9 | ||
|
|
a593bc0b7a | ||
|
|
b6d1af3760 | ||
|
|
3acbcf54db | ||
|
|
673a8f540b | ||
|
|
69e2a138d9 | ||
|
|
11c6e748f7 | ||
|
|
33187ea12f | ||
|
|
6c5ceab4e5 | ||
|
|
f2dc454727 | ||
|
|
8cb0898f1f | ||
|
|
2a2809c17c | ||
|
|
9eeecaa73d | ||
|
|
c83a888ed0 | ||
|
|
6854632fec | ||
|
|
e6cc49368e | ||
|
|
18bf00eb7a | ||
|
|
fcef6f965d | ||
|
|
fb9a9b7c96 | ||
|
|
d662bddeb1 | ||
|
|
c5afeb6d71 | ||
|
|
c66a2b8c61 | ||
|
|
88a66b49ff | ||
|
|
3b07f4a8ca | ||
|
|
f6fd1e1c91 | ||
|
|
2412f6f63a | ||
|
|
ce1983a3b1 | ||
|
|
523f9c7409 | ||
|
|
d5d7c73ebf | ||
|
|
ce93537fee | ||
|
|
5df20d755a | ||
|
|
2eb933c2d4 | ||
|
|
ef3c776b4b | ||
|
|
bf156ad7d2 | ||
|
|
56a2b27745 | ||
|
|
0e7ace0da4 | ||
|
|
6743861630 | ||
|
|
92c6a84075 | ||
|
|
b8a7748dc1 | ||
|
|
8b5c630303 | ||
|
|
9ac5db2f0c | ||
|
|
a7380b33c7 | ||
|
|
4779096ac5 | ||
|
|
43be54ec42 | ||
|
|
7010985be8 | ||
|
|
5080dd4c4b | ||
|
|
1b1acf0aa5 | ||
|
|
5527269283 | ||
|
|
af32e156c2 | ||
|
|
9c7c94b2d4 | ||
|
|
796e5f6f86 | ||
|
|
b282b775d0 | ||
|
|
4da422fd3c | ||
|
|
bf90909496 | ||
|
|
7aa99ce9a7 | ||
|
|
b6767b02ed | ||
|
|
cf5f7ef634 | ||
|
|
3ca4ff9a94 | ||
|
|
28edd18e55 | ||
|
|
7bf2ae3d5e | ||
|
|
7896a525f2 | ||
|
|
f2d5bfe51d | ||
|
|
b2b6f98294 | ||
|
|
4758369f79 | ||
|
|
e10e629d13 | ||
|
|
0e9e39a4eb | ||
|
|
93e5052d6b | ||
|
|
1b471e163d | ||
|
|
556e480b06 | ||
|
|
d08bee3413 | ||
|
|
e83cb6fa8b | ||
|
|
499cdd9f29 | ||
|
|
13aa150206 | ||
|
|
63d6bab7d6 | ||
|
|
d6108fbbf3 | ||
|
|
4c44f1ee02 | ||
|
|
f4c728f57c | ||
|
|
58cebf7346 | ||
|
|
2446792c62 | ||
|
|
259a86b605 | ||
|
|
fafe795f39 | ||
|
|
9a9636b58f | ||
|
|
d48a686d98 | ||
|
|
d69d1c8967 | ||
|
|
60b6a9ff3f | ||
|
|
f85803c1fe | ||
|
|
9dea815fce | ||
|
|
4e01336b2f | ||
|
|
9fbc7c0f65 | ||
|
|
4d028d1867 | ||
|
|
95a46f1ce5 | ||
|
|
26a84c5546 | ||
|
|
652d0d46ed | ||
|
|
e0f3e34899 | ||
|
|
4a8083f7b1 | ||
|
|
08556b111b | ||
|
|
7e7bc13b62 | ||
|
|
5115eb125d | ||
|
|
3ec55b24f8 | ||
|
|
5dba1067d6 | ||
|
|
2a91c67c51 | ||
|
|
a29bc1da8c | ||
|
|
32b4d11a82 | ||
|
|
f013f7357f | ||
|
|
e37150e98a | ||
|
|
eaa7262b2f | ||
|
|
63f4f0bcec | ||
|
|
6dec6cef27 | ||
|
|
4d8faffb75 | ||
|
|
fa6bb07e8a | ||
|
|
d260c48393 | ||
|
|
cc31417c97 | ||
|
|
4d2af5b536 | ||
|
|
6029c8ae4a | ||
|
|
3bc18c3300 | ||
|
|
a652bc65cc | ||
|
|
53252aa797 | ||
|
|
956c1d96af | ||
|
|
d0ecbda962 | ||
|
|
5d880e2b2a | ||
|
|
bb13be1e7a | ||
|
|
05975a0068 | ||
|
|
153feb002e | ||
|
|
f63ed8f269 | ||
|
|
139a503403 | ||
|
|
db4d072bd9 | ||
|
|
42b0842aab | ||
|
|
9ab275195f | ||
|
|
8407f2ff69 | ||
|
|
9d518879dd | ||
|
|
588663b3c2 | ||
|
|
77f8489e51 | ||
|
|
3c08b070fc | ||
|
|
dda5ce4809 | ||
|
|
307be5c75e | ||
|
|
a0b89051cf | ||
|
|
a1025a8e9a | ||
|
|
ce2418ce9f | ||
|
|
ec3540e200 | ||
|
|
ff4311d114 | ||
|
|
425a13e68c | ||
|
|
15af1d3bd1 | ||
|
|
2d57cb4ed0 | ||
|
|
25788ef751 | ||
|
|
b3086e595f | ||
|
|
57e233413a | ||
|
|
f5777d58fc | ||
|
|
6b55cd0128 | ||
|
|
a03c49e12c | ||
|
|
01543dd52b | ||
|
|
987f69227a | ||
|
|
e51841a28b | ||
|
|
bcfe2fef72 | ||
|
|
9ed3f95ab8 |
10
.github/workflows/android.yml
vendored
10
.github/workflows/android.yml
vendored
@@ -8,17 +8,21 @@ on:
|
||||
- '4.**'
|
||||
- '5.**'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: set up JDK 11
|
||||
uses: actions/setup-java@v1
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 11
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
@@ -32,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Archive reports for failed build
|
||||
if: ${{ failure() }}
|
||||
uses: actions/upload-artifact@v2
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: reports
|
||||
path: '*/build/reports'
|
||||
|
||||
7
.github/workflows/docker.yml
vendored
7
.github/workflows/docker.yml
vendored
@@ -4,15 +4,18 @@ on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read # to fetch code (actions/checkout)
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
- name: Build image
|
||||
run: cd reproducible-builds && docker build -t signal-android . && cd ..
|
||||
|
||||
- name: Test build
|
||||
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assembleRelease
|
||||
run: docker run --rm -v $(pwd):/project -w /project signal-android ./gradlew clean assemblePlayProdRelease
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ obj/
|
||||
jni/libspeex/.deps/
|
||||
pkcs11.password
|
||||
dev.keystore
|
||||
maps.key
|
||||
|
||||
@@ -15,11 +15,6 @@ Truths which we believe to be self-evident:
|
||||
1. **There is no such thing as time.** Protocol ideas that require synchronized clocks are doomed to failure.
|
||||
|
||||
|
||||
## Translations
|
||||
|
||||
Thanks to a dedicated community of volunteer translators, Signal is now available in more than one hundred languages. We use Transifex to manage our translation efforts, not GitHub. Any suggestions, corrections, or new translations should be submitted to the [Signal localization project for Android](https://www.transifex.com/signalapp/signal-android/).
|
||||
|
||||
|
||||
## Issues
|
||||
|
||||
### Useful bug reports
|
||||
@@ -75,10 +70,6 @@ There are several other ways to get involved:
|
||||
* Redirect support questions to support@signal.org and the [Signal Support Center](https://support.signal.org/).
|
||||
* Redirect non-bug discussions to the [community forum](https://community.signalusers.org).
|
||||
* Improve documentation in the [wiki](https://github.com/signalapp/Signal-Android/wiki).
|
||||
* Join the community of volunteer translators on Transifex:
|
||||
* [Android](https://www.transifex.com/signalapp/signal-android/)
|
||||
* [iOS](https://www.transifex.com/signalapp/signal-ios/)
|
||||
* [Desktop](https://www.transifex.com/signalapp/signal-desktop/)
|
||||
* Find and mark duplicate issues.
|
||||
* Try to reproduce issues and help with troubleshooting.
|
||||
* Discover solutions to open issues and post any relevant findings.
|
||||
|
||||
15
README.md
15
README.md
@@ -1,10 +1,10 @@
|
||||
# Signal Android
|
||||
|
||||
Signal is a messaging app for simple private communication with friends.
|
||||
Signal is a simple, powerful, and secure messenger.
|
||||
|
||||
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
|
||||
Signal uses your phone's data connection (WiFi/3G/4G/5G) to communicate securely. Millions of people use Signal every day for free and instantaneous communication anywhere in the world. Send and receive high-fidelity messages, participate in HD voice/video calls, and explore a growing set of new features that help you stay connected. Signal’s advanced privacy-preserving technology is always enabled, so you can focus on sharing the moments that matter with the people who matter to you.
|
||||
|
||||
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
|
||||
Currently available on the Play Store and [signal.org](https://signal.org/android/apk/).
|
||||
|
||||
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
|
||||
|
||||
@@ -18,14 +18,9 @@ Want to live life on the bleeding edge and help out with testing?
|
||||
|
||||
You can subscribe to Signal Android Beta releases here:
|
||||
https://play.google.com/apps/testing/org.thoughtcrime.securesms
|
||||
|
||||
|
||||
If you're interested in a life of peace and tranquility, stick with the standard releases.
|
||||
|
||||
## Contributing Translations
|
||||
Interested in helping to translate Signal? Contribute here:
|
||||
|
||||
https://www.transifex.com/projects/p/signal-android/
|
||||
|
||||
## Contributing Code
|
||||
|
||||
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions.
|
||||
@@ -33,7 +28,7 @@ If you're new to the Signal codebase, we recommend going through our issues and
|
||||
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.
|
||||
|
||||
## Contributing Ideas
|
||||
Have something you want to say about Open Whisper Systems projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org).
|
||||
Have something you want to say about Signal projects or want to be part of the conversation? Get involved in the [community forum](https://community.signalusers.org).
|
||||
|
||||
Help
|
||||
====
|
||||
|
||||
173
app/build.gradle
173
app/build.gradle
@@ -1,3 +1,5 @@
|
||||
import com.android.build.api.dsl.ManagedVirtualDevice
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'com.google.protobuf'
|
||||
@@ -11,20 +13,11 @@ apply from: 'static-ips.gradle'
|
||||
|
||||
repositories {
|
||||
maven {
|
||||
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
|
||||
content {
|
||||
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
|
||||
url "https://raw.githubusercontent.com/signalapp/maven/master/sqlcipher/release/"
|
||||
content {
|
||||
includeGroupByRegex "org\\.signal.*"
|
||||
}
|
||||
}
|
||||
maven {
|
||||
url "https://www.jitpack.io"
|
||||
}
|
||||
|
||||
google()
|
||||
mavenCentral()
|
||||
@@ -35,10 +28,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,8 +52,8 @@ ktlint {
|
||||
version = "0.43.2"
|
||||
}
|
||||
|
||||
def canonicalVersionCode = 1055
|
||||
def canonicalVersionName = "5.38.5"
|
||||
def canonicalVersionCode = 1188
|
||||
def canonicalVersionName = "6.7.2"
|
||||
|
||||
def postFixSize = 100
|
||||
def abiPostFix = ['universal' : 0,
|
||||
@@ -82,10 +71,12 @@ def selectableVariants = [
|
||||
'playProdDebug',
|
||||
'playProdSpinner',
|
||||
'playProdPerf',
|
||||
'playProdInstrumentation',
|
||||
'playProdRelease',
|
||||
'playStagingDebug',
|
||||
'playStagingSpinner',
|
||||
'playStagingPerf',
|
||||
'playStagingInstrumentation',
|
||||
'playStagingRelease',
|
||||
'websiteProdSpinner',
|
||||
'websiteProdRelease',
|
||||
@@ -97,16 +88,13 @@ android {
|
||||
|
||||
flavorDimensions 'distribution', 'environment'
|
||||
useLibrary 'org.apache.http.legacy'
|
||||
testBuildType 'instrumentation'
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||
}
|
||||
|
||||
dexOptions {
|
||||
javaMaxHeapSize "4g"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (keystores.debug != null) {
|
||||
debug {
|
||||
@@ -124,6 +112,17 @@ android {
|
||||
unitTests {
|
||||
includeAndroidResources = true
|
||||
}
|
||||
|
||||
managedDevices {
|
||||
devices {
|
||||
pixel3api30 (ManagedVirtualDevice) {
|
||||
device = "Pixel 3"
|
||||
apiLevel = 30
|
||||
systemImageSource = "google-atd"
|
||||
require64Bit = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
@@ -157,8 +156,13 @@ android {
|
||||
exclude 'META-INF/LICENSE'
|
||||
exclude 'META-INF/NOTICE'
|
||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
||||
exclude 'libsignal_jni.dylib'
|
||||
exclude 'signal_jni.dll'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
versionCode canonicalVersionCode * postFixSize
|
||||
@@ -170,7 +174,9 @@ android {
|
||||
multiDexEnabled true
|
||||
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
project.ext.set("archivesBaseName", "Signal");
|
||||
project.ext.set("archivesBaseName", "Signal")
|
||||
|
||||
manifestPlaceholders = [mapsKey:"AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"]
|
||||
|
||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
||||
@@ -179,12 +185,13 @@ 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_CDSH_URL", "\"https://cdsh.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_STAGING_SFU_URL", "\"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
|
||||
@@ -196,19 +203,16 @@ android {
|
||||
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
|
||||
buildConfigField "String", "CDSH_CODE_HASH", "\"2f79dc6c1599b71c70fc2d14f3ea2e3bc65134436eb87011c88845b137af673a\""
|
||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||
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[] {" +
|
||||
"new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
||||
"}"
|
||||
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+ikU5gBXsRmoF94GXQ==\""
|
||||
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('", "') + '"}'
|
||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||
@@ -237,7 +241,7 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner "org.thoughtcrime.securesms.testing.SignalTestRunner"
|
||||
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||
}
|
||||
|
||||
@@ -269,8 +273,21 @@ android {
|
||||
testProguardFiles 'proguard/proguard-automation.pro',
|
||||
'proguard/proguard.cfg'
|
||||
|
||||
manifestPlaceholders = [mapsKey:getMapsKey()]
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||
}
|
||||
|
||||
instrumentation {
|
||||
initWith debug
|
||||
isDefault false
|
||||
minifyEnabled false
|
||||
matchingFallbacks = ['debug']
|
||||
applicationIdSuffix ".instrumentation"
|
||||
|
||||
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Instrumentation\""
|
||||
}
|
||||
|
||||
spinner {
|
||||
initWith debug
|
||||
isDefault false
|
||||
@@ -339,18 +356,17 @@ 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[] {" +
|
||||
"new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
||||
"}"
|
||||
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+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
|
||||
buildConfigField "String", "ZKGROUP_SERVER_PUBLIC_PARAMS", "\"ABSY21VckQcbSXVNCGRYJcfWHiAMZmpTtTELcDmxgdFbtp/bWsSxZdMKzfCp8rvIs8ocCU3B37fT3r4Mi5qAemeGeR2X+/YmOGR5ofui7tD5mDQfstAI9i+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdlukrpzzsCIvEwjwQlJYVPOQPj4V0F4UXXBdHSLK05uoPBCQG8G9rYIGedYsClJXnbrgGYG3eMTG5hnx4X4ntARBgELuMWWUEEfSK0mjXg+/2lPmWcTZWR9nkqgQQP0tbzuiPm74H2wMO4u1Wafe+UwyIlIT9L7KLS19Aw8r4sPrXZSSsOZ6s7M1+rTJN0bI5CKY2PX29y5Ok3jSWufIKcgKOnWoP67d5b2du2ZVJjpjfibNIHbT/cegy/sBLoFwtHogVYUewANUAXIaMPyCLRArsKhfJ5wBtTminG/PAvuBdJ70Z/bXVPf8TVsR292zQ65xwvWTejROW6AZX6aqucUj\""
|
||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
||||
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/staging/registration/generate.html\""
|
||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||
@@ -404,10 +420,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
|
||||
@@ -420,7 +437,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
|
||||
@@ -456,6 +475,10 @@ dependencies {
|
||||
implementation project(':image-editor')
|
||||
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
|
||||
@@ -476,22 +499,18 @@ 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
|
||||
implementation libs.greenrobot.eventbus
|
||||
implementation libs.waitingdots
|
||||
implementation libs.floatingactionbutton
|
||||
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'
|
||||
}
|
||||
@@ -500,16 +519,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.circular.progress.button
|
||||
|
||||
implementation libs.signal.android.database.sqlcipher
|
||||
implementation libs.androidx.sqlite
|
||||
|
||||
@@ -536,11 +548,22 @@ dependencies {
|
||||
force = true
|
||||
}
|
||||
testImplementation testLibs.hamcrest.hamcrest
|
||||
testImplementation testLibs.mockk
|
||||
|
||||
testImplementation(testFixtures(project(":libsignal-service")))
|
||||
|
||||
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||
androidTestImplementation testLibs.espresso.core
|
||||
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
|
||||
|
||||
@@ -552,7 +575,7 @@ dependencies {
|
||||
implementation libs.rxjava3.rxkotlin
|
||||
implementation libs.rxdogtag
|
||||
|
||||
androidTestUtil 'androidx.test:orchestrator:1.4.1'
|
||||
androidTestUtil testLibs.androidx.test.orchestrator
|
||||
}
|
||||
|
||||
def getLastCommitTimestamp() {
|
||||
@@ -561,7 +584,7 @@ def getLastCommitTimestamp() {
|
||||
}
|
||||
|
||||
new ByteArrayOutputStream().withStream { os ->
|
||||
def result = exec {
|
||||
exec {
|
||||
executable = 'git'
|
||||
args = ['log', '-1', '--pretty=format:%ct']
|
||||
standardOutput = os
|
||||
@@ -573,20 +596,20 @@ def getLastCommitTimestamp() {
|
||||
|
||||
def getGitHash() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return "abcd1234"
|
||||
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
exec {
|
||||
commandLine 'git', 'rev-parse', '--short', 'HEAD'
|
||||
commandLine 'git', 'rev-parse', 'HEAD'
|
||||
standardOutput = stdout
|
||||
}
|
||||
return stdout.toString().trim()
|
||||
return stdout.toString().trim().substring(0, 12)
|
||||
}
|
||||
|
||||
def getCurrentGitTag() {
|
||||
if (!(new File('.git').exists())) {
|
||||
return ''
|
||||
throw new IllegalStateException("Must be a git repository to guarantee reproducible builds! (git hash is part of APK)")
|
||||
}
|
||||
|
||||
def stdout = new ByteArrayOutputStream()
|
||||
@@ -620,14 +643,22 @@ def loadKeystoreProperties(filename) {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
def keystoreProperties = new Properties()
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
return keystoreProperties;
|
||||
return keystoreProperties
|
||||
} else {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
def getDateSuffix() {
|
||||
static def getDateSuffix() {
|
||||
def date = new Date()
|
||||
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" />
|
||||
@@ -41,4 +42,5 @@
|
||||
<ignore path="*/org/thoughtcrime/securesms/jobs/StickerPackDownloadJob.java" />
|
||||
</issue>
|
||||
|
||||
<issue id="OptionalUsedAsFieldOrParameterType" severity="ignore" />
|
||||
</lint>
|
||||
|
||||
@@ -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,380 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.changenumber
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.FlakyTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
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
|
||||
@FlakyTest
|
||||
@Ignore("Test sometimes requires manual intervention to continue.")
|
||||
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,154 @@
|
||||
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.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
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.messages.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.messages.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 = OutgoingMessage(
|
||||
recipient = other,
|
||||
body = body,
|
||||
attachments = PointerAttachment.forPointers(Optional.of(attachments)),
|
||||
timestamp = System.currentTimeMillis(),
|
||||
isSecure = true
|
||||
)
|
||||
|
||||
val insert = SignalDatabase.messages.insertMessageOutbox(
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
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 {
|
||||
|
||||
@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("Super really long name like omg", "But seriously it's long like really really long"))
|
||||
|
||||
harness.setVerified(other, IdentityTable.VerifiedStatus.VERIFIED)
|
||||
harness.changeIdentityKey(other)
|
||||
|
||||
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
|
||||
scenario.onActivity {
|
||||
SafetyNumberBottomSheet.forRecipientId(other.id).show(it.supportFragmentManager)
|
||||
}
|
||||
|
||||
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
|
||||
// ThreadUtil.sleep(15000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testShowLargeSheet() {
|
||||
SignalDatabase.distributionLists.setPrivacyMode(DistributionListId.MY_STORY, DistributionListPrivacyMode.ONLY_WITH)
|
||||
|
||||
val othersRecipients = harness.others.map { Recipient.resolved(it) }
|
||||
othersRecipients.forEach { other ->
|
||||
SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("My", "Name"))
|
||||
|
||||
harness.setVerified(other, IdentityTable.VerifiedStatus.DEFAULT)
|
||||
harness.changeIdentityKey(other)
|
||||
|
||||
SignalDatabase.distributionLists.addMemberToList(DistributionListId.MY_STORY, DistributionListPrivacyMode.ONLY_WITH, other.id)
|
||||
}
|
||||
|
||||
val myStoryRecipientId = SignalDatabase.distributionLists.getRecipientId(DistributionListId.MY_STORY)!!
|
||||
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", harness.others.first().serialize()) }
|
||||
scenario.onActivity { conversationActivity ->
|
||||
SafetyNumberBottomSheet
|
||||
.forIdentityRecordsAndDestinations(
|
||||
identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords,
|
||||
destinations = listOf(ContactSearchKey.RecipientSearchKey.Story(myStoryRecipientId))
|
||||
)
|
||||
.show(conversationActivity.supportFragmentManager)
|
||||
}
|
||||
|
||||
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
|
||||
// ThreadUtil.sleep( 30000)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.mms.MediaStream
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.util.Optional
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AttachmentTableTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
SignalDatabase.attachments.deleteAllAttachments()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlob_whenIInsert2AttachmentsForPreUpload_thenIExpectDistinctIdsButSameFileName() {
|
||||
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
|
||||
val highQualityProperties = createHighQualityTransformProperties()
|
||||
val highQualityImage = createAttachment(1, blob, highQualityProperties)
|
||||
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
|
||||
val attachment2 = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
|
||||
|
||||
assertNotEquals(attachment2.attachmentId, attachment.attachmentId)
|
||||
assertEquals(attachment2.fileName, attachment.fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlobAndDifferentTransformQuality_whenIInsert2AttachmentsForPreUpload_thenIExpectDifferentFileInfos() {
|
||||
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
|
||||
val highQualityProperties = createHighQualityTransformProperties()
|
||||
val highQualityImage = createAttachment(1, blob, highQualityProperties)
|
||||
val lowQualityImage = createAttachment(1, blob, AttachmentTable.TransformProperties.empty())
|
||||
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
|
||||
val attachment2 = SignalDatabase.attachments.insertAttachmentForPreUpload(lowQualityImage)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
|
||||
false
|
||||
)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment2,
|
||||
createMediaStream(byteArrayOf(1, 2, 3)),
|
||||
false
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIdenticalAttachmentsInsertedForPreUpload_whenIUpdateAttachmentDataAndSpecifyOnlyModifyThisAttachment_thenIExpectDifferentFileInfos() {
|
||||
val blob = BlobProvider.getInstance().forData(byteArrayOf(1, 2, 3, 4, 5)).createForSingleSessionInMemory()
|
||||
val highQualityProperties = createHighQualityTransformProperties()
|
||||
val highQualityImage = createAttachment(1, blob, highQualityProperties)
|
||||
val attachment = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
|
||||
val attachment2 = SignalDatabase.attachments.insertAttachmentForPreUpload(highQualityImage)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4, 5)),
|
||||
true
|
||||
)
|
||||
|
||||
SignalDatabase.attachments.updateAttachmentData(
|
||||
attachment2,
|
||||
createMediaStream(byteArrayOf(1, 2, 3, 4)),
|
||||
true
|
||||
)
|
||||
|
||||
val attachment1Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment.attachmentId, AttachmentTable.DATA)
|
||||
val attachment2Info = SignalDatabase.attachments.getAttachmentDataFileInfo(attachment2.attachmentId, AttachmentTable.DATA)
|
||||
|
||||
assertNotEquals(attachment1Info, attachment2Info)
|
||||
}
|
||||
|
||||
private fun createAttachment(id: Long, uri: Uri, transformProperties: AttachmentTable.TransformProperties): UriAttachment {
|
||||
return UriAttachmentBuilder.build(
|
||||
id,
|
||||
uri = uri,
|
||||
contentType = MediaUtil.IMAGE_JPEG,
|
||||
transformProperties = transformProperties
|
||||
)
|
||||
}
|
||||
|
||||
private fun createHighQualityTransformProperties(): AttachmentTable.TransformProperties {
|
||||
return AttachmentTable.TransformProperties.forSentMediaQuality(Optional.empty(), SentMediaQuality.HIGH)
|
||||
}
|
||||
|
||||
private fun createMediaStream(byteArray: ByteArray): MediaStream {
|
||||
return MediaStream(byteArray.inputStream(), MediaUtil.IMAGE_JPEG, 2, 2)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import java.util.UUID
|
||||
|
||||
class DistributionListDatabaseTest {
|
||||
class DistributionListTablesTest {
|
||||
|
||||
private lateinit var distributionDatabase: DistributionListDatabase
|
||||
private lateinit var distributionDatabase: DistributionListTables
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
@@ -80,25 +80,6 @@ class DistributionListDatabaseTest {
|
||||
Assert.assertEquals(StoryType.STORY_WITHOUT_REPLIES, storyType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExistsAndMarkedNoReplies_getAllListsForContactSelectionUi_returnsStoryWithoutReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
distributionDatabase.setAllowsReplies(id!!, false)
|
||||
|
||||
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
|
||||
Assert.assertFalse(records.first().allowsReplies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenStoryExists_getAllListsForContactSelectionUi_returnsStoryWithReplies() {
|
||||
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||
Assert.assertNotNull(id)
|
||||
|
||||
val records = distributionDatabase.getAllListsForContactSelectionUi(null, false)
|
||||
Assert.assertTrue(records.first().allowsReplies)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun givenStoryDoesNotExist_getStoryType_throwsIllegalStateException() {
|
||||
distributionDatabase.getStoryType(DistributionListId.from(12))
|
||||
@@ -1,37 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.money.FiatMoney
|
||||
import org.thoughtcrime.securesms.database.model.DonationReceiptRecord
|
||||
import java.math.BigDecimal
|
||||
import java.util.Currency
|
||||
|
||||
class DonationReceiptDatabaseTest {
|
||||
|
||||
private val records = listOf(
|
||||
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(100), Currency.getInstance("USD"))),
|
||||
DonationReceiptRecord.createForBoost(FiatMoney(BigDecimal.valueOf(200), Currency.getInstance("USD")))
|
||||
)
|
||||
|
||||
@Test
|
||||
fun givenNoReceipts_whenICheckHasReceipts_thenIExpectFalse() {
|
||||
assertFalse(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOneReceipt_whenICheckHasReceipts_thenIExpectTrue() {
|
||||
SignalDatabase.donationReceipts.addReceipt(records.first())
|
||||
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleReceipts_whenICheckHasReceipts_thenIExpectTrue() {
|
||||
records.forEach {
|
||||
SignalDatabase.donationReceipts.addReceipt(it)
|
||||
}
|
||||
|
||||
assertTrue(SignalDatabase.donationReceipts.hasReceipts())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageTableTest_gifts {
|
||||
private lateinit var mms: MessageTable
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
private lateinit var recipients: List<RecipientId>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mms = SignalDatabase.messages
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoSentGifts_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(1))
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSentGift_whenISetOutgoingGiftsRevealed_thenIExpectNonEmptyListContainingThatGift() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
|
||||
assertTrue(result.isNotEmpty())
|
||||
assertEquals(messageId, result.first().messageId.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenViewedSentGift_whenISetOutgoingGiftsRevealed_thenIExpectEmptyList() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForOne_thenIExpectNonEmptyListContainingThatGift() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId))
|
||||
|
||||
assertEquals(1, result.size)
|
||||
assertEquals(messageId, result.first().messageId.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleSentGift_whenISetOutgoingGiftsRevealedForBoth_thenIExpectNonEmptyListContainingThoseGifts() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
|
||||
|
||||
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForBothGifts_thenIExpectNonEmptyListContainingJustThoseGifts() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = null
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2))
|
||||
|
||||
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForAllThree_thenIExpectNonEmptyListContainingJustThoseGifts() {
|
||||
val messageId = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val messageId2 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val messageId3 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = null
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId, messageId2, messageId3))
|
||||
|
||||
assertEquals(listOf(messageId, messageId2), result.map { it.messageId.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenMultipleSentGiftAndNonGift_whenISetOutgoingGiftsRevealedForNonGift_thenIExpectEmptyList() {
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = GiftBadge.getDefaultInstance()
|
||||
)
|
||||
|
||||
val messageId3 = MmsHelper.insert(
|
||||
recipient = Recipient.resolved(recipients[0]),
|
||||
sentTimeMillis = 1,
|
||||
giftBadge = null
|
||||
)
|
||||
|
||||
val result = mms.setOutgoingGiftsRevealed(listOf(messageId3))
|
||||
|
||||
assertTrue(result.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MmsDatabaseTest_stories {
|
||||
|
||||
private lateinit var mms: MmsDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
private lateinit var myStory: Recipient
|
||||
private lateinit var recipients: List<RecipientId>
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mms = SignalDatabase.mms
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectAnEmptyList() {
|
||||
// WHEN
|
||||
val result = mms.orderedStoryRecipientsAndIds
|
||||
|
||||
// THEN
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOneOutgoingAndOneIncomingStory_whenIGetOrderedStoryRecipientsAndIds_thenIExpectIncomingThenOutgoing() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
val sender = recipients[0]
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 1,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = threadId
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.orderedStoryRecipientsAndIds
|
||||
|
||||
// THEN
|
||||
assertEquals(listOf(sender.toLong(), myStory.id.toLong()), result.map { it.recipientId.toLong() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAStory_whenISetIncomingStoryMessageViewed_thenIExpectASetReceiptTimestamp() {
|
||||
// GIVEN
|
||||
val sender = recipients[0]
|
||||
val messageId = MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
|
||||
val messageBeforeMark = SignalDatabase.mms.getMessageRecord(messageId)
|
||||
assertFalse(messageBeforeMark.incomingStoryViewedAtTimestamp > 0)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.mms.setIncomingMessageViewed(messageId)
|
||||
|
||||
// THEN
|
||||
val messageAfterMark = SignalDatabase.mms.getMessageRecord(messageId)
|
||||
assertTrue(messageAfterMark.incomingStoryViewedAtTimestamp > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given5ViewedStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectLatestViewedFirst() {
|
||||
// GIVEN
|
||||
val messageIds = recipients.take(5).map {
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = it,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val randomizedOrderedIds = messageIds.shuffled()
|
||||
randomizedOrderedIds.forEach {
|
||||
SignalDatabase.mms.setIncomingMessageViewed(it)
|
||||
Thread.sleep(5)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
val result = SignalDatabase.mms.orderedStoryRecipientsAndIds
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
// THEN
|
||||
assertEquals(randomizedOrderedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given15Stories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectUnviewedThenInterspersedViewedAndSelfSendsAllDescending() {
|
||||
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
|
||||
val unviewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val viewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val interspersedIds: List<Long> = (0 until 10).map {
|
||||
Thread.sleep(5)
|
||||
if (it % 2 == 0) {
|
||||
SignalDatabase.mms.setIncomingMessageViewed(viewedIds[it / 2])
|
||||
viewedIds[it / 2]
|
||||
} else {
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = myStoryThread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val result = SignalDatabase.mms.orderedStoryRecipientsAndIds
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import java.util.Optional
|
||||
|
||||
@@ -18,29 +20,27 @@ object MmsHelper {
|
||||
subscriptionId: Int = -1,
|
||||
expiresIn: Long = 0,
|
||||
viewOnce: Boolean = false,
|
||||
distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT,
|
||||
threadId: Long = 1,
|
||||
storyType: StoryType = StoryType.NONE
|
||||
distributionType: Int = ThreadTable.DistributionTypes.DEFAULT,
|
||||
threadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(recipient, distributionType),
|
||||
storyType: StoryType = StoryType.NONE,
|
||||
parentStoryId: ParentStoryId? = null,
|
||||
isStoryReaction: Boolean = false,
|
||||
giftBadge: GiftBadge? = null,
|
||||
secure: Boolean = true
|
||||
): Long {
|
||||
val message = OutgoingMediaMessage(
|
||||
recipient,
|
||||
body,
|
||||
emptyList(),
|
||||
sentTimeMillis,
|
||||
subscriptionId,
|
||||
expiresIn,
|
||||
viewOnce,
|
||||
distributionType,
|
||||
storyType,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptyList(),
|
||||
emptySet(),
|
||||
emptySet(),
|
||||
null
|
||||
val message = OutgoingMessage(
|
||||
recipient = recipient,
|
||||
body = body,
|
||||
timestamp = sentTimeMillis,
|
||||
subscriptionId = subscriptionId,
|
||||
expiresIn = expiresIn,
|
||||
viewOnce = viewOnce,
|
||||
distributionType = distributionType,
|
||||
storyType = storyType,
|
||||
parentStoryId = parentStoryId,
|
||||
isStoryReaction = isStoryReaction,
|
||||
giftBadge = giftBadge,
|
||||
isSecure = secure
|
||||
)
|
||||
|
||||
return insert(
|
||||
@@ -50,16 +50,16 @@ object MmsHelper {
|
||||
}
|
||||
|
||||
fun insert(
|
||||
message: OutgoingMediaMessage,
|
||||
message: OutgoingMessage,
|
||||
threadId: Long
|
||||
): Long {
|
||||
return SignalDatabase.mms.insertMessageOutbox(message, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null)
|
||||
return SignalDatabase.messages.insertMessageOutbox(message, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null)
|
||||
}
|
||||
|
||||
fun insert(
|
||||
message: IncomingMediaMessage,
|
||||
threadId: Long
|
||||
): Optional<MessageDatabase.InsertResult> {
|
||||
return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId)
|
||||
): Optional<MessageTable.InsertResult> {
|
||||
return SignalDatabase.messages.insertSecureDecryptedMessageInbox(message, threadId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.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
|
||||
import org.thoughtcrime.securesms.database.model.ParentStoryId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MmsTableTest_stories {
|
||||
|
||||
private lateinit var mms: MessageTable
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
private lateinit var myStory: Recipient
|
||||
private lateinit var recipients: List<RecipientId>
|
||||
private lateinit var releaseChannelRecipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mms = SignalDatabase.messages
|
||||
|
||||
mms.deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
|
||||
myStory = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(DistributionListId.MY_STORY))
|
||||
recipients = (0 until 5).map { SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())) }
|
||||
releaseChannelRecipient = Recipient.resolved(SignalDatabase.recipients.insertReleaseChannelRecipient())
|
||||
|
||||
SignalStore.releaseChannelValues().setReleaseChannelRecipientId(releaseChannelRecipient.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectAnEmptyList() {
|
||||
// WHEN
|
||||
val result = mms.getOrderedStoryRecipientsAndIds(false)
|
||||
|
||||
// THEN
|
||||
assertEquals(0, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOneOutgoingAndOneIncomingStory_whenIGetOrderedStoryRecipientsAndIds_thenIExpectIncomingThenOutgoing() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
val sender = recipients[0]
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 1,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = threadId
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.getOrderedStoryRecipientsAndIds(false)
|
||||
|
||||
// THEN
|
||||
assertEquals(listOf(sender.toLong(), myStory.id.toLong()), result.map { it.recipientId.toLong() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAStory_whenISetIncomingStoryMessageViewed_thenIExpectASetReceiptTimestamp() {
|
||||
// GIVEN
|
||||
val sender = recipients[0]
|
||||
val messageId = MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = sender,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
|
||||
val messageBeforeMark = SignalDatabase.messages.getMessageRecord(messageId)
|
||||
assertFalse(messageBeforeMark.incomingStoryViewedAtTimestamp > 0)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.messages.setIncomingMessageViewed(messageId)
|
||||
|
||||
// THEN
|
||||
val messageAfterMark = SignalDatabase.messages.getMessageRecord(messageId)
|
||||
assertTrue(messageAfterMark.incomingStoryViewedAtTimestamp > 0)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun given5ViewedStories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectLatestViewedFirst() {
|
||||
// GIVEN
|
||||
val messageIds = recipients.take(5).map {
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = it,
|
||||
sentTimeMillis = 2,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val randomizedOrderedIds = messageIds.shuffled()
|
||||
randomizedOrderedIds.forEach {
|
||||
SignalDatabase.messages.setIncomingMessageViewed(it)
|
||||
Thread.sleep(5)
|
||||
}
|
||||
|
||||
// WHEN
|
||||
val result = SignalDatabase.messages.getOrderedStoryRecipientsAndIds(false)
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
// THEN
|
||||
assertEquals(randomizedOrderedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun given15Stories_whenIGetOrderedStoryRecipientsAndIds_thenIExpectUnviewedThenInterspersedViewedAndSelfSendsAllDescending() {
|
||||
val myStoryThread = SignalDatabase.threads.getOrCreateThreadIdFor(myStory)
|
||||
|
||||
val unviewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val viewedIds: List<Long> = (0 until 5).map {
|
||||
Thread.sleep(5)
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[it],
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
).get().messageId
|
||||
}
|
||||
|
||||
val interspersedIds: List<Long> = (0 until 10).map {
|
||||
Thread.sleep(5)
|
||||
if (it % 2 == 0) {
|
||||
SignalDatabase.messages.setIncomingMessageViewed(viewedIds[it / 2])
|
||||
viewedIds[it / 2]
|
||||
} else {
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = myStoryThread
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val result = SignalDatabase.messages.getOrderedStoryRecipientsAndIds(false)
|
||||
val resultOrderedIds = result.map { it.messageId }
|
||||
|
||||
assertEquals(unviewedIds.reversed() + interspersedIds.reversed(), resultOrderedIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
|
||||
// WHEN
|
||||
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoOutgoingStories_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectFalse() {
|
||||
// GIVEN
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = recipients[0],
|
||||
sentTimeMillis = 200,
|
||||
serverTimeMillis = 2,
|
||||
receivedTimeMillis = 2,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
),
|
||||
-1L
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.isOutgoingStoryAlreadyInDatabase(recipients[0], 200)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenOutgoingStoryExistsForRecipientAndTime_whenICheckIsOutgoingStoryAlreadyInDatabase_thenIExpectTrue() {
|
||||
// GIVEN
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 200,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.isOutgoingStoryAlreadyInDatabase(myStory.id, 200)
|
||||
|
||||
// THEN
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupStoryWithNoReplies_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
|
||||
// GIVEN
|
||||
val groupStoryId = MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 200,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = -1L
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Ignore
|
||||
@Test
|
||||
fun givenAGroupStoryWithAReplyFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
|
||||
// GIVEN
|
||||
val groupStoryId = MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 200,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = -1L
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 201,
|
||||
storyType = StoryType.NONE,
|
||||
parentStoryId = ParentStoryId.GroupReply(groupStoryId)
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupStoryWithAReactionFromSelf_whenICheckHasSelfReplyInGroupStory_thenIExpectTrue() {
|
||||
// GIVEN
|
||||
val groupStoryId = MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 200,
|
||||
storyType = StoryType.STORY_WITH_REPLIES
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 201,
|
||||
storyType = StoryType.NONE,
|
||||
parentStoryId = ParentStoryId.GroupReply(groupStoryId),
|
||||
isStoryReaction = true
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertTrue(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAGroupStoryWithAReplyFromSomeoneElse_whenICheckHasSelfReplyInGroupStory_thenIExpectFalse() {
|
||||
// GIVEN
|
||||
val groupStoryId = MmsHelper.insert(
|
||||
recipient = myStory,
|
||||
sentTimeMillis = 200,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = -1L
|
||||
)
|
||||
|
||||
MmsHelper.insert(
|
||||
IncomingMediaMessage(
|
||||
from = myStory.id,
|
||||
sentTimeMillis = 201,
|
||||
serverTimeMillis = 201,
|
||||
receivedTimeMillis = 202,
|
||||
parentStoryId = ParentStoryId.GroupReply(groupStoryId)
|
||||
),
|
||||
-1
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val result = mms.hasGroupReplyOrReactionInStory(groupStoryId)
|
||||
|
||||
// THEN
|
||||
assertFalse(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNotViewedOnboardingAndOnlyStoryIsOnboardingAndAdded2DaysAgo_whenIGetOldestStoryTimestamp_thenIExpectNull() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(releaseChannelRecipient)
|
||||
MmsHelper.insert(
|
||||
recipient = releaseChannelRecipient,
|
||||
sentTimeMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2),
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = threadId
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val oldestTimestamp = SignalDatabase.messages.getOldestStorySendTimestamp(false)
|
||||
|
||||
// THEN
|
||||
assertNull(oldestTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenViewedOnboardingAndOnlyStoryIsOnboardingAndAdded2DaysAgo_whenIGetOldestStoryTimestamp_thenIExpectNotNull() {
|
||||
// GIVEN
|
||||
val expected = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(2)
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(releaseChannelRecipient)
|
||||
MmsHelper.insert(
|
||||
recipient = releaseChannelRecipient,
|
||||
sentTimeMillis = expected,
|
||||
storyType = StoryType.STORY_WITH_REPLIES,
|
||||
threadId = threadId
|
||||
)
|
||||
|
||||
// WHEN
|
||||
val oldestTimestamp = SignalDatabase.messages.getOldestStorySendTimestamp(true)
|
||||
|
||||
// THEN
|
||||
assertEquals(expected, oldestTimestamp)
|
||||
}
|
||||
}
|
||||
@@ -1,786 +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.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_getAndPossiblyMerge {
|
||||
|
||||
private lateinit var recipientDatabase: RecipientDatabase
|
||||
private lateinit var identityDatabase: IdentityDatabase
|
||||
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
|
||||
private lateinit var groupDatabase: GroupDatabase
|
||||
private lateinit var threadDatabase: ThreadDatabase
|
||||
private lateinit var smsDatabase: MessageDatabase
|
||||
private lateinit var mmsDatabase: MessageDatabase
|
||||
private lateinit var sessionDatabase: SessionDatabase
|
||||
private lateinit var mentionDatabase: MentionDatabase
|
||||
private lateinit var reactionDatabase: ReactionDatabase
|
||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
||||
private lateinit var distributionListDatabase: DistributionListDatabase
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
|
||||
@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_highTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
/** If all you have is an ACI, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly_lowTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, false)
|
||||
|
||||
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_highTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
assertFalse(recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only_lowTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, false)
|
||||
|
||||
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_highTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertEquals(E164_A, recipient.requireE164())
|
||||
}
|
||||
|
||||
/** With low trust, you cannot associate an ACI-e164 pair, and therefore can only store the ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164_lowTrust() {
|
||||
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
|
||||
val recipient = Recipient.resolved(recipientId)
|
||||
assertEquals(ACI_A, recipient.requireServiceId())
|
||||
assertFalse(recipient.hasE164())
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If the ACI maps to an existing user, but the E164 doesn't
|
||||
// ==============================================================
|
||||
|
||||
/** With high trust, you can associate an e164 with an existing ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** With low trust, you cannot associate an ACI-e164 pair, and therefore cannot store the e164. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_lowTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
}
|
||||
|
||||
/** Basically the ‘change number’ case. High trust lets you update the existing user. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** Low trust means you can’t update the underlying data, but you also don’t need to create any new rows. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_lowTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, false)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// If the E164 maps to an existing user, but the ACI doesn't
|
||||
// ==============================================================
|
||||
|
||||
/** With high trust, you can associate an e164 with an existing ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** With low trust, you cannot associate an ACI-e164 pair, and therefore need to create a new person with just the ACI. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_lowTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertNotEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingRecipient = Recipient.resolved(existingId)
|
||||
assertEquals(E164_A, existingRecipient.requireE164())
|
||||
assertFalse(existingRecipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. But high trust lets us take the e164 from the current holder. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
recipientDatabase.setPni(existingId, PNI_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
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 change the ACI of an existing row. New ACI = new person, regardless of trust. And low trust means we can’t take the e164. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_lowTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, false)
|
||||
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())
|
||||
}
|
||||
|
||||
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_e164BelongsToLocalUser_highTrust() {
|
||||
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.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
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
|
||||
// ==============================================================
|
||||
|
||||
/** Regardless of trust, if your ACI and e164 match, you’re good. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_highTrust() {
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
}
|
||||
|
||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assertFalse(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Same as [getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust], but with a number change. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingAciId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
changeNumberListener.waitForJobManager()
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Low trust means you can’t merge. If you’re retrieving a user from the table with this data, prefer the ACI one. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_lowTrust() {
|
||||
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertEquals(existingAciId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertFalse(retrievedRecipient.hasE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||
assertEquals(E164_A, existingE164Recipient.requireE164())
|
||||
assertFalse(existingE164Recipient.hasServiceId())
|
||||
}
|
||||
|
||||
/** Another high trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_highTrust() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
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())
|
||||
|
||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
||||
}
|
||||
|
||||
/** Another low trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_lowTrust() {
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||
assertEquals(ACI_B, existingRecipient2.requireServiceId())
|
||||
assertEquals(E164_A, existingRecipient2.requireE164())
|
||||
}
|
||||
|
||||
/**
|
||||
* Another high trust 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_highTrust() {
|
||||
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
assertEquals(existingId1, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
assertFalse(recipientDatabase.getByE164(E164_B).isPresent)
|
||||
|
||||
val recipientWithId2 = Recipient.resolved(existingId2)
|
||||
assertEquals(retrievedId, recipientWithId2.id)
|
||||
}
|
||||
|
||||
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
|
||||
@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.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
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_highTrust_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.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, 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_highTrust_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.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, highTrust = true, 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_highTrust_changedNumber() {
|
||||
val changeNumberListener = ChangeNumberListener()
|
||||
changeNumberListener.enqueue()
|
||||
|
||||
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||
assertEquals(existingId, retrievedId)
|
||||
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||
|
||||
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. */
|
||||
@Test
|
||||
fun getAndPossiblyMerge_merge_general() {
|
||||
// Setup
|
||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
||||
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
|
||||
|
||||
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
||||
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
||||
|
||||
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
|
||||
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
|
||||
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
|
||||
assertNotEquals(threadIdAci, threadIdE164)
|
||||
|
||||
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
|
||||
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
|
||||
|
||||
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
|
||||
|
||||
val identityKeyAci: IdentityKey = identityKey(1)
|
||||
val identityKeyE164: IdentityKey = identityKey(2)
|
||||
|
||||
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
|
||||
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
|
||||
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
|
||||
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
|
||||
|
||||
val profile1: NotificationProfile = notificationProfile(name = "Test")
|
||||
val profile2: NotificationProfile = notificationProfile(name = "Test2")
|
||||
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
||||
|
||||
val distributionListId: DistributionListId = distributionListDatabase.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
||||
assertEquals(recipientIdAci, retrievedId)
|
||||
|
||||
// Recipient validation
|
||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||
|
||||
val existingE164Recipient = Recipient.resolved(recipientIdE164)
|
||||
assertEquals(retrievedId, existingE164Recipient.id)
|
||||
|
||||
// Thread validation
|
||||
assertEquals(threadIdAci, retrievedThreadId)
|
||||
Assert.assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
|
||||
Assert.assertNull(threadDatabase.getThreadRecord(threadIdE164))
|
||||
|
||||
// SMS validation
|
||||
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
|
||||
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
|
||||
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
|
||||
|
||||
assertEquals(retrievedId, sms1.recipient.id)
|
||||
assertEquals(retrievedId, sms2.recipient.id)
|
||||
assertEquals(retrievedId, sms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, sms1.threadId)
|
||||
assertEquals(retrievedThreadId, sms2.threadId)
|
||||
assertEquals(retrievedThreadId, sms3.threadId)
|
||||
|
||||
// MMS validation
|
||||
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
|
||||
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
|
||||
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
|
||||
|
||||
assertEquals(retrievedId, mms1.recipient.id)
|
||||
assertEquals(retrievedId, mms2.recipient.id)
|
||||
assertEquals(retrievedId, mms3.recipient.id)
|
||||
|
||||
assertEquals(retrievedThreadId, mms1.threadId)
|
||||
assertEquals(retrievedThreadId, mms2.threadId)
|
||||
assertEquals(retrievedThreadId, mms3.threadId)
|
||||
|
||||
// Mention validation
|
||||
val mention1: MentionModel = getMention(mmsId1)
|
||||
assertEquals(retrievedId, mention1.recipientId)
|
||||
assertEquals(retrievedThreadId, mention1.threadId)
|
||||
|
||||
val mention2: MentionModel = getMention(mmsId2)
|
||||
assertEquals(retrievedId, mention2.recipientId)
|
||||
assertEquals(retrievedThreadId, mention2.threadId)
|
||||
|
||||
// Group receipt validation
|
||||
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
|
||||
assertEquals(retrievedId, groupReceipts[0].recipientId)
|
||||
assertEquals(retrievedId, groupReceipts[1].recipientId)
|
||||
|
||||
// Identity validation
|
||||
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
|
||||
Assert.assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
|
||||
|
||||
// Session validation
|
||||
Assert.assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
|
||||
// Reaction validation
|
||||
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
|
||||
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
|
||||
|
||||
assertEquals(1, reactionsSms.size)
|
||||
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
|
||||
|
||||
assertEquals(1, reactionsMms.size)
|
||||
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
|
||||
|
||||
// Notification Profile validation
|
||||
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
|
||||
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
|
||||
|
||||
MatcherAssert.assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
|
||||
MatcherAssert.assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
|
||||
// Distribution List validation
|
||||
val updatedList: DistributionListRecord = distributionListDatabase.getList(distributionListId)!!
|
||||
|
||||
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
// ==============================================================
|
||||
// Misc
|
||||
// ==============================================================
|
||||
|
||||
@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.getAndPossiblyMerge(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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientTableTest {
|
||||
|
||||
@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, RecipientTable.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, RecipientTable.ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}!!
|
||||
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(hiddenRecipient in results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIQueryAllContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
|
||||
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
|
||||
|
||||
val results = SignalDatabase.recipients.queryAllContacts("Blocked")!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIGetSignalContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
|
||||
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
|
||||
|
||||
val results: MutableList<RecipientId> = SignalDatabase.recipients.getSignalContacts(false)?.use {
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (it.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientTable.ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}!!
|
||||
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(blockedRecipient in results)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIQuerySignalContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
|
||||
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
|
||||
|
||||
val results = SignalDatabase.recipients.querySignalContacts("Blocked", false)!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIQueryNonGroupContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
|
||||
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
|
||||
|
||||
val results = SignalDatabase.recipients.queryNonGroupContacts("Blocked", false)!!
|
||||
|
||||
assertEquals(0, results.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenABlockedRecipient_whenIGetNonGroupContacts_thenIDoNotExpectBlockedToBeReturned() {
|
||||
val blockedRecipient = harness.others[0]
|
||||
SignalDatabase.recipients.setProfileName(blockedRecipient, ProfileName.fromParts("Blocked", "Person"))
|
||||
SignalDatabase.recipients.setBlocked(blockedRecipient, true)
|
||||
|
||||
val results: MutableList<RecipientId> = SignalDatabase.recipients.getNonGroupContacts(false)?.use {
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (it.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(it, RecipientTable.ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}!!
|
||||
|
||||
assertNotEquals(0, results.size)
|
||||
assertFalse(blockedRecipient in results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.core.content.contentValuesOf
|
||||
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.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.select
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.SignalProtocolAddress
|
||||
import org.signal.libsignal.protocol.state.SessionRecord
|
||||
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.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.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.util.Optional
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientTableTest_getAndPossiblyMerge {
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
SignalStore.account().setE164(E164_SELF)
|
||||
SignalStore.account().setAci(ACI_SELF)
|
||||
SignalStore.account().setPni(PNI_SELF)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allSimpleTests() {
|
||||
test("no match, e164-only") {
|
||||
process(E164_A, null, null)
|
||||
expect(E164_A, null, null)
|
||||
}
|
||||
|
||||
test("no match, e164 and pni") {
|
||||
process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
}
|
||||
|
||||
test("no match, aci-only") {
|
||||
process(null, null, ACI_A)
|
||||
expect(null, null, ACI_A)
|
||||
}
|
||||
|
||||
test("no match, e164 and aci") {
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("no match, no data", exception = java.lang.IllegalArgumentException::class.java) {
|
||||
process(null, null, null)
|
||||
}
|
||||
|
||||
test("no match, all fields") {
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("full match") {
|
||||
given(E164_A, PNI_A, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 matches, all fields provided") {
|
||||
given(E164_A, null, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 matches, e164 and aci provided") {
|
||||
given(E164_A, null, null)
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 matches, all provided, different aci") {
|
||||
given(E164_A, null, ACI_B)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(null, null, ACI_B)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 matches, e164 and aci provided, different aci") {
|
||||
given(E164_A, null, ACI_A)
|
||||
|
||||
process(E164_A, null, ACI_B)
|
||||
|
||||
expect(null, null, ACI_A)
|
||||
expect(E164_A, null, ACI_B)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, new aci") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 and aci matches, all provided, new pni") {
|
||||
given(E164_A, null, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, new e164 and aci") {
|
||||
given(null, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni and aci matches, all provided, new e164") {
|
||||
given(null, PNI_A, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("e164 and aci matches, e164 and aci provided, nothing new") {
|
||||
given(E164_A, null, ACI_A)
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("aci matches, all provided, new e164 and pni") {
|
||||
given(null, null, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("aci matches, e164 and aci provided") {
|
||||
given(null, null, ACI_A)
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("aci matches, local user, changeSelf=false") {
|
||||
given(E164_SELF, PNI_SELF, ACI_SELF)
|
||||
|
||||
process(E164_SELF, null, ACI_B)
|
||||
|
||||
expect(E164_SELF, PNI_SELF, ACI_SELF)
|
||||
expect(null, null, ACI_B)
|
||||
}
|
||||
|
||||
test("e164 matches, e164 and pni provided, pni changes, no pni session") {
|
||||
given(E164_A, PNI_B, null)
|
||||
process(E164_A, PNI_A, null)
|
||||
expect(E164_A, PNI_A, null)
|
||||
}
|
||||
|
||||
test("e164 and pni matches, all provided, no existing session") {
|
||||
given(E164_A, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("pni matches, all provided, no existing session") {
|
||||
given(null, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
// 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.
|
||||
test("pni matches, no existing pni session, changes number") {
|
||||
given(E164_B, PNI_A, null)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
// 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.
|
||||
test("pni and aci matches, change number") {
|
||||
given(E164_B, PNI_A, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("aci matches, all provided, change number") {
|
||||
given(E164_B, null, ACI_A)
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("aci matches, e164 and aci provided, change number") {
|
||||
given(E164_B, null, ACI_A)
|
||||
process(E164_A, null, ACI_A)
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("steal, e164+pni & e164+pni, no aci provided, no sessions") {
|
||||
given(E164_A, PNI_B, null)
|
||||
given(E164_B, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expect(E164_B, null, null)
|
||||
}
|
||||
|
||||
test("steal, e164+pni & aci, e164 record has separate e164") {
|
||||
given(E164_B, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_B, null, null)
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("steal, e164+aci & e164+aci, change number") {
|
||||
given(E164_B, null, ACI_A)
|
||||
given(E164_A, null, ACI_B)
|
||||
|
||||
process(E164_A, null, ACI_A)
|
||||
|
||||
expect(E164_A, null, ACI_A)
|
||||
expect(null, null, ACI_B)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni & aci, all provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, no aci provided") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expectDeleted()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni, aci provided but no aci record") {
|
||||
given(E164_A, null, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
expectDeleted()
|
||||
}
|
||||
|
||||
test("merge, e164 & pni+e164, no aci provided") {
|
||||
given(E164_A, null, null)
|
||||
given(E164_B, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expect(E164_B, null, null)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & pni, no aci provided") {
|
||||
given(E164_A, PNI_B, null)
|
||||
given(null, PNI_A, null)
|
||||
|
||||
process(E164_A, PNI_A, null)
|
||||
|
||||
expect(E164_A, PNI_A, null)
|
||||
expectDeleted()
|
||||
}
|
||||
|
||||
test("merge, e164+pni & aci") {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
}
|
||||
|
||||
test("merge, e164+pni & e164+pni+aci, change number") {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(E164_B, PNI_B, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("merge, e164+pni & e164+aci, change number") {
|
||||
given(E164_A, PNI_A, null)
|
||||
given(E164_B, null, ACI_A)
|
||||
|
||||
process(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, PNI_A, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("merge, e164 & aci") {
|
||||
given(E164_A, null, null)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_A, null, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, null, ACI_A)
|
||||
}
|
||||
|
||||
test("merge, e164 & e164+aci, change number") {
|
||||
given(E164_A, null, null)
|
||||
given(E164_B, null, ACI_A)
|
||||
|
||||
process(E164_A, null, ACI_A)
|
||||
|
||||
expectDeleted()
|
||||
expect(E164_A, null, ACI_A)
|
||||
|
||||
expectChangeNumberEvent()
|
||||
}
|
||||
|
||||
test("local user, local e164 and aci provided, changeSelf=false, leave e164 alone") {
|
||||
given(E164_SELF, null, ACI_SELF)
|
||||
given(null, null, ACI_A)
|
||||
|
||||
process(E164_SELF, null, ACI_A)
|
||||
|
||||
expect(E164_SELF, null, ACI_SELF)
|
||||
expect(null, null, ACI_A)
|
||||
}
|
||||
|
||||
test("local user, e164 and aci provided, changeSelf=false, leave e164 alone") {
|
||||
given(E164_SELF, null, ACI_SELF)
|
||||
process(E164_A, null, ACI_SELF)
|
||||
expect(E164_SELF, null, ACI_SELF)
|
||||
}
|
||||
|
||||
test("local user, e164 and aci provided, changeSelf=true, change e164") {
|
||||
given(E164_SELF, null, ACI_SELF)
|
||||
process(E164_A, null, ACI_SELF, changeSelf = true)
|
||||
expect(E164_A, null, ACI_SELF)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Somewhat exhaustive test of verifying all the data that gets merged.
|
||||
*/
|
||||
@Test
|
||||
fun getAndPossiblyMerge_merge_general() {
|
||||
// Setup
|
||||
val recipientIdAci: RecipientId = SignalDatabase.recipients.getOrInsertFromServiceId(ACI_A)
|
||||
val recipientIdE164: RecipientId = SignalDatabase.recipients.getOrInsertFromE164(E164_A)
|
||||
val recipientIdAciB: RecipientId = SignalDatabase.recipients.getOrInsertFromServiceId(ACI_B)
|
||||
|
||||
val smsId1: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
||||
val smsId2: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
||||
val smsId3: Long = SignalDatabase.messages.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
||||
|
||||
val mmsId1: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
||||
val mmsId2: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
||||
val mmsId3: Long = SignalDatabase.messages.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
||||
|
||||
val threadIdAci: Long = SignalDatabase.threads.getThreadIdFor(recipientIdAci)!!
|
||||
val threadIdE164: Long = SignalDatabase.threads.getThreadIdFor(recipientIdE164)!!
|
||||
Assert.assertNotEquals(threadIdAci, threadIdE164)
|
||||
|
||||
SignalDatabase.mentions.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
|
||||
SignalDatabase.mentions.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
|
||||
|
||||
SignalDatabase.groupReceipts.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
|
||||
|
||||
val identityKeyAci: IdentityKey = identityKey(1)
|
||||
val identityKeyE164: IdentityKey = identityKey(2)
|
||||
|
||||
SignalDatabase.identities.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityTable.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
SignalDatabase.identities.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityTable.VerifiedStatus.VERIFIED, false, 0, false)
|
||||
|
||||
SignalDatabase.sessions.store(ACI_SELF, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
||||
|
||||
SignalDatabase.reactions.addReaction(MessageId(smsId1), ReactionRecord("a", recipientIdAci, 1, 1))
|
||||
SignalDatabase.reactions.addReaction(MessageId(mmsId1), ReactionRecord("b", recipientIdE164, 1, 1))
|
||||
|
||||
val profile1: NotificationProfile = notificationProfile(name = "Test")
|
||||
val profile2: NotificationProfile = notificationProfile(name = "Test2")
|
||||
|
||||
SignalDatabase.notificationProfiles.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
|
||||
SignalDatabase.notificationProfiles.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
|
||||
SignalDatabase.notificationProfiles.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
||||
SignalDatabase.notificationProfiles.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
||||
|
||||
val distributionListId: DistributionListId = SignalDatabase.distributionLists.createList("testlist", listOf(recipientIdE164, recipientIdAciB))!!
|
||||
|
||||
// Merge
|
||||
val retrievedId: RecipientId = SignalDatabase.recipients.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||
val retrievedThreadId: Long = SignalDatabase.threads.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(SignalDatabase.threads.getThreadIdFor(recipientIdE164))
|
||||
Assert.assertNull(SignalDatabase.threads.getThreadRecord(threadIdE164))
|
||||
|
||||
// SMS validation
|
||||
val sms1: MessageRecord = SignalDatabase.messages.getMessageRecord(smsId1)!!
|
||||
val sms2: MessageRecord = SignalDatabase.messages.getMessageRecord(smsId2)!!
|
||||
val sms3: MessageRecord = SignalDatabase.messages.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 = SignalDatabase.messages.getMessageRecord(mmsId1)!!
|
||||
val mms2: MessageRecord = SignalDatabase.messages.getMessageRecord(mmsId2)!!
|
||||
val mms3: MessageRecord = SignalDatabase.messages.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<GroupReceiptTable.GroupReceiptInfo> = SignalDatabase.groupReceipts.getGroupReceiptInfo(mmsId1)
|
||||
assertEquals(retrievedId, groupReceipts[0].recipientId)
|
||||
assertEquals(retrievedId, groupReceipts[1].recipientId)
|
||||
|
||||
// Identity validation
|
||||
assertEquals(identityKeyAci, SignalDatabase.identities.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
|
||||
Assert.assertNull(SignalDatabase.identities.getIdentityStoreRecord(E164_A))
|
||||
|
||||
// Session validation
|
||||
Assert.assertNotNull(SignalDatabase.sessions.load(ACI_SELF, SignalProtocolAddress(ACI_A.toString(), 1)))
|
||||
|
||||
// Reaction validation
|
||||
val reactionsSms: List<ReactionRecord> = SignalDatabase.reactions.getReactions(MessageId(smsId1))
|
||||
val reactionsMms: List<ReactionRecord> = SignalDatabase.reactions.getReactions(MessageId(mmsId1))
|
||||
|
||||
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 = SignalDatabase.notificationProfiles.getProfile(profile1.id)!!
|
||||
val updatedProfile2: NotificationProfile = SignalDatabase.notificationProfiles.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 = SignalDatabase.distributionLists.getList(distributionListId)!!
|
||||
|
||||
MatcherAssert.assertThat("Distribution list should have updated $recipientIdE164 to $recipientIdAci", updatedList.members, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
||||
}
|
||||
|
||||
private fun smsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingTextMessage {
|
||||
return IncomingTextMessage(sender, 1, time, time, time, body, groupId, 0, true, null)
|
||||
}
|
||||
|
||||
private fun mmsMessage(sender: RecipientId, time: Long = 0, body: String = "", groupId: Optional<GroupId> = Optional.empty()): IncomingMediaMessage {
|
||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.empty(), false, false)
|
||||
}
|
||||
|
||||
private fun identityKey(value: Byte): IdentityKey {
|
||||
val bytes = ByteArray(33)
|
||||
bytes[0] = 0x05
|
||||
bytes[1] = value
|
||||
return IdentityKey(bytes)
|
||||
}
|
||||
|
||||
private fun notificationProfile(name: String): NotificationProfile {
|
||||
return (SignalDatabase.notificationProfiles.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
|
||||
}
|
||||
|
||||
private fun getMention(messageId: Long): MentionModel {
|
||||
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionTable.TABLE_NAME} WHERE ${MentionTable.MESSAGE_ID} = $messageId").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
return MentionModel(
|
||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionTable.RECIPIENT_ID)),
|
||||
threadId = CursorUtil.requireLong(cursor, MentionTable.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
|
||||
)
|
||||
|
||||
/**
|
||||
* Baby DSL for making tests readable.
|
||||
*/
|
||||
private fun test(name: String, init: TestCase.() -> Unit): TestCase {
|
||||
// Weird issue with generics wouldn't let me make the exception an arg with default value -- had to do an actual overload
|
||||
return test(name, null as Class<Throwable>?, init)
|
||||
}
|
||||
|
||||
/**
|
||||
* Baby DSL for making tests readable.
|
||||
*/
|
||||
private fun <E> test(name: String, exception: Class<E>?, init: TestCase.() -> Unit): TestCase where E : Throwable {
|
||||
val test = TestCase()
|
||||
try {
|
||||
test.init()
|
||||
|
||||
if (exception != null) {
|
||||
throw java.lang.AssertionError("Expected $exception, but none was thrown.")
|
||||
}
|
||||
|
||||
if (!test.changeNumberExpected) {
|
||||
test.expectNoChangeNumberEvent()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
if (e.javaClass != exception) {
|
||||
val error = java.lang.AssertionError("[$name] ${e.message}")
|
||||
error.stackTrace = e.stackTrace
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return test
|
||||
}
|
||||
|
||||
private inner class TestCase {
|
||||
private val generatedIds: LinkedHashSet<RecipientId> = LinkedHashSet()
|
||||
private var expectCount = 0
|
||||
private lateinit var outputRecipientId: RecipientId
|
||||
|
||||
var changeNumberExpected = false
|
||||
|
||||
init {
|
||||
// Need to delete these first to prevent foreign key crash
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM distribution_list")
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM distribution_list_member")
|
||||
|
||||
SqlUtil.getAllTables(SignalDatabase.rawDatabase)
|
||||
.filterNot { it.contains("sqlite") || it.contains("fts") || it.startsWith("emoji_search_") } // If we delete these we'll corrupt the DB
|
||||
.sorted()
|
||||
.forEach { table ->
|
||||
SignalDatabase.rawDatabase.execSQL("DELETE FROM $table")
|
||||
}
|
||||
|
||||
ApplicationDependencies.getRecipientCache().clear()
|
||||
RecipientId.clearCache()
|
||||
}
|
||||
|
||||
fun given(
|
||||
e164: String?,
|
||||
pni: PNI?,
|
||||
aci: ACI?,
|
||||
createThread: Boolean = true,
|
||||
sms: List<String> = emptyList(),
|
||||
mms: List<String> = emptyList()
|
||||
) {
|
||||
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
|
||||
SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(id))
|
||||
SignalDatabase.messages.insertMessageInbox(IncomingEncryptedMessage(IncomingTextMessage(id, 1, 0, 0, 0, "", Optional.empty(), 0, false, ""), ""))
|
||||
}
|
||||
}
|
||||
|
||||
fun process(e164: String?, pni: PNI?, aci: ACI?, changeSelf: Boolean = false) {
|
||||
outputRecipientId = SignalDatabase.recipients.getAndPossiblyMerge(serviceId = aci ?: pni, pni = pni, e164 = e164, pniVerified = false, changeSelf = changeSelf)
|
||||
generatedIds += outputRecipientId
|
||||
}
|
||||
|
||||
fun expect(e164: String?, pni: PNI?, aci: ACI?) {
|
||||
expect(generatedIds.elementAt(expectCount++), e164, pni, aci)
|
||||
}
|
||||
|
||||
fun expect(id: RecipientId, e164: String?, pni: PNI?, aci: ACI?) {
|
||||
val recipient = Recipient.resolved(id)
|
||||
val expected = RecipientTuple(
|
||||
e164 = e164,
|
||||
pni = pni,
|
||||
serviceId = aci ?: pni
|
||||
)
|
||||
val actual = RecipientTuple(
|
||||
e164 = recipient.e164.orElse(null),
|
||||
pni = recipient.pni.orElse(null),
|
||||
serviceId = recipient.serviceId.orElse(null)
|
||||
)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
fun expectDeleted() {
|
||||
expectDeleted(generatedIds.elementAt(expectCount++))
|
||||
}
|
||||
|
||||
fun expectDeleted(id: RecipientId) {
|
||||
SignalDatabase.rawDatabase
|
||||
.select("1")
|
||||
.from(RecipientTable.TABLE_NAME)
|
||||
.where("${RecipientTable.ID} = ?", id)
|
||||
.run()
|
||||
.use { !it.moveToFirst() }
|
||||
}
|
||||
|
||||
fun expectChangeNumberEvent() {
|
||||
assertEquals(1, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
changeNumberExpected = true
|
||||
}
|
||||
|
||||
fun expectNoChangeNumberEvent() {
|
||||
assertEquals(0, SignalDatabase.messages.getChangeNumberMessageCount(outputRecipientId))
|
||||
changeNumberExpected = false
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val serviceIdString: String? = (aci ?: pni)?.toString()
|
||||
val pniString: String? = pni?.toString()
|
||||
|
||||
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||
RecipientTable.TABLE_NAME,
|
||||
null,
|
||||
contentValuesOf(
|
||||
RecipientTable.PHONE to e164,
|
||||
RecipientTable.SERVICE_ID to serviceIdString,
|
||||
RecipientTable.PNI_COLUMN to pniString,
|
||||
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue("Failed to insert! E164: $e164, ServiceId: $serviceIdString, PNI: $pniString", id > 0)
|
||||
|
||||
return RecipientId.from(id)
|
||||
}
|
||||
}
|
||||
|
||||
data class RecipientTuple(
|
||||
val e164: String?,
|
||||
val pni: PNI?,
|
||||
val serviceId: ServiceId?
|
||||
) {
|
||||
|
||||
/**
|
||||
* The intent here is to give nice diffs with the name of the constants rather than the values.
|
||||
*/
|
||||
override fun toString(): String {
|
||||
return "(${e164.e164String()}, ${pni.pniString()}, ${serviceId.serviceIdString()})"
|
||||
}
|
||||
|
||||
private fun String?.e164String(): String {
|
||||
return this?.let {
|
||||
when (it) {
|
||||
E164_A -> "E164_A"
|
||||
E164_B -> "E164_B"
|
||||
else -> it
|
||||
}
|
||||
} ?: "null"
|
||||
}
|
||||
|
||||
private fun PNI?.pniString(): String {
|
||||
return this?.let {
|
||||
when (it) {
|
||||
PNI_A -> "PNI_A"
|
||||
PNI_B -> "PNI_B"
|
||||
PNI_SELF -> "PNI_SELF"
|
||||
else -> it.toString()
|
||||
}
|
||||
} ?: "null"
|
||||
}
|
||||
|
||||
private fun ServiceId?.serviceIdString(): String {
|
||||
return this?.let {
|
||||
when (it) {
|
||||
PNI_A -> "PNI_A"
|
||||
PNI_B -> "PNI_B"
|
||||
PNI_SELF -> "PNI_SELF"
|
||||
ACI_A -> "ACI_A"
|
||||
ACI_B -> "ACI_B"
|
||||
ACI_SELF -> "ACI_SELF"
|
||||
else -> it.toString()
|
||||
}
|
||||
} ?: "null"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val ACI_A = ACI.from(UUID.fromString("aaaa0000-5a76-47fa-a98a-7e72c948a82e"))
|
||||
val ACI_B = ACI.from(UUID.fromString("bbbb0000-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||
val ACI_SELF = ACI.from(UUID.fromString("77770000-b477-4f35-a824-d92987a63641"))
|
||||
|
||||
val PNI_A = PNI.from(UUID.fromString("aaaa1111-c960-4f6c-8385-671ad2ffb999"))
|
||||
val PNI_B = PNI.from(UUID.fromString("bbbb1111-cd55-40bf-adda-c35a85375533"))
|
||||
val PNI_SELF = PNI.from(UUID.fromString("77771111-b014-41fb-bf73-05cb2ec52910"))
|
||||
|
||||
const val E164_A = "+12222222222"
|
||||
const val E164_B = "+13333333333"
|
||||
const val E164_SELF = "+10000000000"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
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.IllegalStateException
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RecipientTableTest_processPnpTupleToChangeSet {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
private lateinit var db: RecipientTable
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
db = SignalDatabase.recipients
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164Only() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, null, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, null, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_e164AndPni() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, null, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, null)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_aciOnly() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(null, null, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(null, null, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException::class)
|
||||
fun noMatch_noData() {
|
||||
db.processPnpTupleToChangeSet(null, null, null, pniVerified = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noMatch_allFields() {
|
||||
val changeSet = db.processPnpTupleToChangeSet(E164_A, PNI_A, ACI_A, pniVerified = false)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpInsert(E164_A, PNI_A, ACI_A)
|
||||
),
|
||||
changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullMatch() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyE164Matches_pniChanges_noAciProvided_noPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun e164AndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_A, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_noExistingSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyPniMatches_existingPniSession_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetAci(result.id, ACI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
),
|
||||
PnpOperation.SessionSwitchoverInsert(result.id)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pniAndAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, PNI_A, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches() {
|
||||
val result = applyAndAssert(
|
||||
Input(null, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyAciMatches_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
Input(E164_B, null, ACI_A),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.id),
|
||||
operations = listOf(
|
||||
PnpOperation.SetE164(result.id, E164_A),
|
||||
PnpOperation.SetPni(result.id, PNI_A),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.id,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
Input(null, null, ACI_A)
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.thirdId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.thirdId,
|
||||
secondaryId = result.firstId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniOnly_aciProvidedButNoAciRecord() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, ACI_A),
|
||||
Output(E164_A, PNI_A, ACI_A)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
PnpOperation.SetAci(
|
||||
recipientId = result.firstId,
|
||||
aci = ACI_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164Only_pniAndE164_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, null, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(
|
||||
recipientId = result.firstId,
|
||||
pni = PNI_A
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_pniOnly_noAciProvided() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(null, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.firstId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.firstId,
|
||||
secondaryId = result.secondId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_noSessions() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null),
|
||||
Input(E164_B, PNI_A, null),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPni_noAciProvided_sessionsExist() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_B, null, pniSession = true),
|
||||
Input(E164_B, PNI_A, null, pniSession = true),
|
||||
),
|
||||
Update(E164_A, PNI_A, null),
|
||||
Output(E164_A, PNI_A, null)
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PnpChangeSet(
|
||||
id = PnpIdResolver.PnpNoopId(result.firstId),
|
||||
operations = listOf(
|
||||
PnpOperation.RemovePni(result.secondId),
|
||||
PnpOperation.SetPni(result.firstId, PNI_A),
|
||||
PnpOperation.SessionSwitchoverInsert(result.secondId),
|
||||
PnpOperation.SessionSwitchoverInsert(result.firstId)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(null, 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.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_aciOnly_e164RecordHasSeparateE164() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_B, PNI_A, null),
|
||||
Input(null, 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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
PnpOperation.SetE164(
|
||||
recipientId = result.secondId,
|
||||
e164 = E164_A,
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_C,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164AndPniAndAci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, PNI_B, 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.secondId),
|
||||
PnpOperation.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun merge_e164AndPni_e164Aci_changeNumber() {
|
||||
val result = applyAndAssert(
|
||||
listOf(
|
||||
Input(E164_A, PNI_A, null),
|
||||
Input(E164_B, 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.RemoveE164(result.secondId),
|
||||
PnpOperation.Merge(
|
||||
primaryId = result.secondId,
|
||||
secondaryId = result.firstId
|
||||
),
|
||||
PnpOperation.ChangeNumberInsert(
|
||||
recipientId = result.secondId,
|
||||
oldE164 = E164_B,
|
||||
newE164 = E164_A
|
||||
)
|
||||
)
|
||||
),
|
||||
result.changeSet
|
||||
)
|
||||
}
|
||||
|
||||
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||
RecipientTable.TABLE_NAME,
|
||||
null,
|
||||
contentValuesOf(
|
||||
RecipientTable.PHONE to e164,
|
||||
RecipientTable.SERVICE_ID to (aci ?: pni)?.toString(),
|
||||
RecipientTable.PNI_COLUMN to pni?.toString(),
|
||||
RecipientTable.REGISTERED to RecipientTable.RegisteredState.REGISTERED.id
|
||||
)
|
||||
)
|
||||
|
||||
return RecipientId.from(id)
|
||||
}
|
||||
|
||||
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
|
||||
SignalDatabase.rawDatabase.insert(
|
||||
SessionTable.TABLE_NAME, null,
|
||||
contentValuesOf(
|
||||
SessionTable.ACCOUNT_ID to account.toString(),
|
||||
SessionTable.ADDRESS to address.toString(),
|
||||
SessionTable.DEVICE to 1,
|
||||
SessionTable.RECORD to Util.getSecretBytes(32)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
data class Input(val e164: String?, val pni: PNI?, val aci: ACI?, val pniSession: Boolean = false, val aciSession: Boolean = false)
|
||||
data class Update(val e164: String?, val pni: PNI?, val aci: ACI?, val pniVerified: Boolean = false)
|
||||
data class Output(val e164: String?, val pni: PNI?, val aci: ACI?)
|
||||
data class PnpMatchResult(val ids: List<RecipientId>, val changeSet: PnpChangeSet) {
|
||||
val id
|
||||
get() = if (ids.size == 1) {
|
||||
ids[0]
|
||||
} else {
|
||||
throw IllegalStateException("There are multiple IDs, but you assumed 1!")
|
||||
}
|
||||
|
||||
val firstId
|
||||
get() = ids[0]
|
||||
|
||||
val secondId
|
||||
get() = ids[1]
|
||||
|
||||
val thirdId
|
||||
get() = ids[2]
|
||||
}
|
||||
|
||||
private fun applyAndAssert(input: Input, update: Update, output: Output): PnpMatchResult {
|
||||
return applyAndAssert(listOf(input), update, output)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method that will call insert your recipients, call [RecipientTable.processPnpTupleToChangeSet] with your params,
|
||||
* and then verify your output matches what you expect.
|
||||
*
|
||||
* It results the inserted ID's and changeset for additional verification.
|
||||
*
|
||||
* But basically this is here to make the tests more readable. It gives you a clear list of:
|
||||
* - input
|
||||
* - update
|
||||
* - output
|
||||
*
|
||||
* that you can spot check easily.
|
||||
*
|
||||
* Important: The output will only include records that contain fields from the input. That means
|
||||
* for:
|
||||
*
|
||||
* Input: E164_B, PNI_A, null
|
||||
* Update: E164_A, PNI_A, null
|
||||
*
|
||||
* You will get:
|
||||
* Output: E164_A, PNI_A, null
|
||||
*
|
||||
* Even though there was an update that will also result in the row (E164_B, null, null)
|
||||
*/
|
||||
private fun applyAndAssert(input: List<Input>, update: Update, output: Output): PnpMatchResult {
|
||||
val ids = input.map { insert(it.e164, it.pni, it.aci) }
|
||||
|
||||
input
|
||||
.filter { it.pniSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.pni!!) }
|
||||
|
||||
input
|
||||
.filter { it.aciSession }
|
||||
.forEach { insertMockSessionFor(databaseRule.localAci, it.aci!!) }
|
||||
|
||||
val byE164 = update.e164?.let { db.getByE164(it).orElse(null) }
|
||||
val byPniSid = update.pni?.let { db.getByServiceId(it).orElse(null) }
|
||||
val byAciSid = update.aci?.let { db.getByServiceId(it).orElse(null) }
|
||||
|
||||
val data = PnpDataSet(
|
||||
e164 = update.e164,
|
||||
pni = update.pni,
|
||||
aci = update.aci,
|
||||
byE164 = byE164,
|
||||
byPniSid = byPniSid,
|
||||
byPniOnly = update.pni?.let { db.getByPni(it).orElse(null) },
|
||||
byAciSid = byAciSid,
|
||||
e164Record = byE164?.let { db.getRecord(it) },
|
||||
pniSidRecord = byPniSid?.let { db.getRecord(it) },
|
||||
aciSidRecord = byAciSid?.let { db.getRecord(it) }
|
||||
)
|
||||
val changeSet = db.processPnpTupleToChangeSet(update.e164, update.pni, update.aci, pniVerified = update.pniVerified)
|
||||
|
||||
val finalData = data.perform(changeSet.operations)
|
||||
|
||||
val finalRecords = setOfNotNull(finalData.e164Record, finalData.pniSidRecord, finalData.aciSidRecord)
|
||||
assertEquals("There's still multiple records in the resulting record set! $finalRecords", 1, finalRecords.size)
|
||||
|
||||
finalRecords.firstOrNull { record -> record.e164 == output.e164 && record.pni == output.pni && record.serviceId == (output.aci ?: output.pni) }
|
||||
?: throw AssertionError("Expected output was not found in the result set! Expected: $output")
|
||||
|
||||
return PnpMatchResult(
|
||||
ids = ids,
|
||||
changeSet = changeSet
|
||||
)
|
||||
}
|
||||
|
||||
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"
|
||||
const val E164_C = "+14441234567"
|
||||
}
|
||||
}
|
||||
@@ -122,4 +122,62 @@ class SQLiteDatabaseTest {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransaction() {
|
||||
val hasRun = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
try {
|
||||
db.beginTransaction()
|
||||
hasRun.set(true)
|
||||
db.setTransactionSuccessful()
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
}
|
||||
}
|
||||
|
||||
assertFalse(hasRun.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runPostSuccessfulTransaction_runsAndPerformsAnotherTransactionAndRunPostNested() {
|
||||
val hasRun1 = AtomicBoolean(false)
|
||||
val hasRun2 = AtomicBoolean(false)
|
||||
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
db.beginTransaction()
|
||||
|
||||
db.runPostSuccessfulTransaction {
|
||||
assertTrue(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
hasRun2.set(true)
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
hasRun1.set(true)
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
}
|
||||
|
||||
assertFalse(hasRun1.get())
|
||||
assertFalse(hasRun2.get())
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
db.endTransaction()
|
||||
|
||||
assertTrue(hasRun1.get())
|
||||
assertTrue(hasRun2.get())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ import java.util.UUID
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
|
||||
private lateinit var recipients: RecipientDatabase
|
||||
private lateinit var sms: SmsDatabase
|
||||
private lateinit var recipients: RecipientTable
|
||||
private lateinit var sms: MessageTable
|
||||
|
||||
private val localAci = ACI.from(UUID.randomUUID())
|
||||
private val localPni = PNI.from(UUID.randomUUID())
|
||||
@@ -45,7 +45,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipients = SignalDatabase.recipients
|
||||
sms = SignalDatabase.sms
|
||||
sms = SignalDatabase.messages
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
@@ -163,7 +163,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
*/
|
||||
@Test
|
||||
fun previousJoinRequestCollapse() {
|
||||
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||
val latestMessage: MessageTable.InsertResult = sms.insertMessageInbox(
|
||||
groupUpdateMessage(
|
||||
sender = alice,
|
||||
groupContext = groupContext(masterKey = masterKey) {
|
||||
@@ -197,7 +197,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
fun previousJoinThenTextCollapse() {
|
||||
val secondLatestMessage = sms.insertMessageInbox(smsMessage(sender = alice, body = "What up")).get()
|
||||
|
||||
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||
val latestMessage: MessageTable.InsertResult = sms.insertMessageInbox(
|
||||
groupUpdateMessage(
|
||||
sender = alice,
|
||||
groupContext = groupContext(masterKey = masterKey) {
|
||||
@@ -231,7 +231,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
*/
|
||||
@Test
|
||||
fun previousCollapseAndJoinRequestDoubleCollapse() {
|
||||
val secondLatestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||
val secondLatestMessage: MessageTable.InsertResult = sms.insertMessageInbox(
|
||||
groupUpdateMessage(
|
||||
sender = alice,
|
||||
groupContext = groupContext(masterKey = masterKey) {
|
||||
@@ -243,7 +243,7 @@ class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||
)
|
||||
).get()
|
||||
|
||||
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||
val latestMessage: MessageTable.InsertResult = sms.insertMessageInbox(
|
||||
groupUpdateMessage(
|
||||
sender = alice,
|
||||
groupContext = groupContext(masterKey = masterKey) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -22,7 +23,7 @@ import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class StorySendsDatabaseTest {
|
||||
class StorySendTableTest {
|
||||
|
||||
private val distributionId1 = DistributionId.from(UUID.randomUUID())
|
||||
private val distributionId2 = DistributionId.from(UUID.randomUUID())
|
||||
@@ -45,7 +46,7 @@ class StorySendsDatabaseTest {
|
||||
private var messageId2: Long = 0
|
||||
private var messageId3: Long = 0
|
||||
|
||||
private lateinit var storySends: StorySendsDatabase
|
||||
private lateinit var storySends: StorySendTable
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
@@ -183,7 +184,7 @@ class StorySendsDatabaseTest {
|
||||
@Test
|
||||
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
|
||||
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||
|
||||
@@ -280,19 +281,20 @@ class StorySendsDatabaseTest {
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
|
||||
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
|
||||
|
||||
assertNotNull(manifest)
|
||||
}
|
||||
|
||||
/*
|
||||
@Test
|
||||
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId1, recipients11to20, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
@@ -304,7 +306,7 @@ class StorySendsDatabaseTest {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId1)
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||
@@ -317,14 +319,14 @@ class StorySendsDatabaseTest {
|
||||
fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||
SignalDatabase.mms.markAsRemoteDelete(messageId2)
|
||||
SignalDatabase.messages.markAsRemoteDelete(messageId2)
|
||||
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||
|
||||
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||
|
||||
assertTrue(results.entries.all { it.allowedToReply })
|
||||
}
|
||||
|
||||
*/
|
||||
@Test
|
||||
fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() {
|
||||
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||
@@ -354,8 +356,8 @@ class StorySendsDatabaseTest {
|
||||
assertEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() {
|
||||
@Test(expected = NoSuchMessageException::class)
|
||||
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeDeleted() {
|
||||
val messageId4 = MmsHelper.insert(
|
||||
recipient = distributionListRecipient1,
|
||||
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||
@@ -375,7 +377,8 @@ class StorySendsDatabaseTest {
|
||||
|
||||
storySends.applySentStoryManifest(remote, 200)
|
||||
|
||||
assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete)
|
||||
SignalDatabase.messages.getMessageRecord(messageId5)
|
||||
fail("Expected messageId5 to no longer exist.")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -399,7 +402,7 @@ class StorySendsDatabaseTest {
|
||||
|
||||
storySends.applySentStoryManifest(remote, 200)
|
||||
|
||||
assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete)
|
||||
assertFalse(SignalDatabase.messages.getMessageRecord(messageId4).isRemoteDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
class ThreadTableTest_pinned {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIDoNotDeleteOrUnpinTheThread() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.messages.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val pinned = SignalDatabase.threads.getPinnedThreadIds()
|
||||
assertTrue(threadId in pinned)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIExpectTheThreadInUnarchivedCount() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.messages.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
val unarchivedCount = SignalDatabase.threads.getUnarchivedConversationListCount(ConversationFilter.OFF)
|
||||
assertEquals(1, unarchivedCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenAPinnedThread_whenIDeleteTheLastMessage_thenIExpectPinnedThreadInUnarchivedList() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
val messageId = MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.pinConversations(listOf(threadId))
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.messages.deleteMessage(messageId)
|
||||
|
||||
// THEN
|
||||
SignalDatabase.threads.getUnarchivedConversationList(ConversationFilter.OFF, true, 0, 1).use {
|
||||
it.moveToFirst()
|
||||
assertEquals(threadId, CursorUtil.requireLong(it, ThreadTable.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.CursorUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ThreadTableTest_recents {
|
||||
|
||||
@Rule
|
||||
@JvmField
|
||||
val databaseRule = SignalDatabaseRule()
|
||||
|
||||
private lateinit var recipient: Recipient
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
recipient = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARecentRecipient_whenIBlockAndGetRecents_thenIDoNotExpectToSeeThatRecipient() {
|
||||
// GIVEN
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
MmsHelper.insert(recipient = recipient, threadId = threadId)
|
||||
SignalDatabase.threads.update(threadId, true)
|
||||
|
||||
// WHEN
|
||||
SignalDatabase.recipients.setBlocked(recipient.id, true)
|
||||
val results: MutableList<RecipientId> = SignalDatabase.threads.getRecentConversationList(10, false, false, false, false, false, false).use { cursor ->
|
||||
val ids = mutableListOf<RecipientId>()
|
||||
while (cursor.moveToNext()) {
|
||||
ids.add(RecipientId.from(CursorUtil.requireLong(cursor, ThreadTable.RECIPIENT_ID)))
|
||||
}
|
||||
|
||||
ids
|
||||
}
|
||||
|
||||
// THEN
|
||||
assertFalse(recipient.id in results)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.net.Uri
|
||||
import org.thoughtcrime.securesms.attachments.UriAttachment
|
||||
import org.thoughtcrime.securesms.audio.AudioHash
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator
|
||||
|
||||
object UriAttachmentBuilder {
|
||||
fun build(
|
||||
id: Long,
|
||||
uri: Uri = Uri.parse("content://$id"),
|
||||
contentType: String,
|
||||
transferState: Int = AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
size: Long = 0L,
|
||||
fileName: String = "file$id",
|
||||
voiceNote: Boolean = false,
|
||||
borderless: Boolean = false,
|
||||
videoGif: Boolean = false,
|
||||
quote: Boolean = false,
|
||||
caption: String? = null,
|
||||
stickerLocator: StickerLocator? = null,
|
||||
blurHash: BlurHash? = null,
|
||||
audioHash: AudioHash? = null,
|
||||
transformProperties: AttachmentTable.TransformProperties? = null
|
||||
): UriAttachment {
|
||||
return UriAttachment(
|
||||
uri,
|
||||
contentType,
|
||||
transferState,
|
||||
size,
|
||||
fileName,
|
||||
voiceNote,
|
||||
borderless,
|
||||
videoGif,
|
||||
quote,
|
||||
caption,
|
||||
stickerLocator,
|
||||
blurHash,
|
||||
audioHash,
|
||||
transformProperties
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.thoughtcrime.securesms.database.DistributionListTables
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||
import org.whispersystems.signalservice.api.push.DistributionId
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MyStoryMigrationTest {
|
||||
|
||||
@get:Rule val harness = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||
|
||||
@Test
|
||||
fun givenAValidMyStory_whenIMigrate_thenIExpectMyStoryToBeValid() {
|
||||
// GIVEN
|
||||
assertValidMyStoryExists()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenNoMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
deleteMyStory()
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenA00000000DistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId("0000-0000")
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenARandomDistributionIdForMyStory_whenIMigrate_thenIExpectMyStoryToBeCreated() {
|
||||
// GIVEN
|
||||
setMyStoryDistributionId(UUID.randomUUID().toString())
|
||||
|
||||
// WHEN
|
||||
runMigration()
|
||||
|
||||
// THEN
|
||||
assertValidMyStoryExists()
|
||||
}
|
||||
|
||||
private fun setMyStoryDistributionId(serializedId: String) {
|
||||
SignalDatabase.rawDatabase.update(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
contentValuesOf(
|
||||
DistributionListTables.DISTRIBUTION_ID to serializedId
|
||||
),
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun deleteMyStory() {
|
||||
SignalDatabase.rawDatabase.delete(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
"_id = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertValidMyStoryExists() {
|
||||
SignalDatabase.rawDatabase.query(
|
||||
DistributionListTables.LIST_TABLE_NAME,
|
||||
SqlUtil.COUNT,
|
||||
"_id = ? AND ${DistributionListTables.DISTRIBUTION_ID} = ?",
|
||||
SqlUtil.buildArgs(DistributionListId.MY_STORY, DistributionId.MY_STORY.toString()),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).use {
|
||||
if (it.moveToNext()) {
|
||||
val count = it.getInt(0)
|
||||
assertEquals("assertValidMyStoryExists: Query produced an unexpected count.", 1, count)
|
||||
} else {
|
||||
fail("assertValidMyStoryExists: Query did not produce a count.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun runMigration() {
|
||||
V151_MyStoryMigration.migrate(
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application,
|
||||
SignalDatabase.rawDatabase,
|
||||
0,
|
||||
1
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.recipients.LiveRecipientCache
|
||||
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
|
||||
private val recipientCache: LiveRecipientCache
|
||||
|
||||
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()
|
||||
|
||||
recipientCache = LiveRecipientCache(application) { r -> r.run() }
|
||||
}
|
||||
|
||||
override fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess {
|
||||
return serviceNetworkAccessMock
|
||||
}
|
||||
|
||||
override fun provideKeyBackupService(signalServiceAccountManager: SignalServiceAccountManager, keyStore: KeyStore, enclave: KbsEnclave): KeyBackupService {
|
||||
return keyBackupService
|
||||
}
|
||||
|
||||
override fun provideRecipientCache(): LiveRecipientCache {
|
||||
return recipientCache
|
||||
}
|
||||
|
||||
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.MessageTable
|
||||
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.messages.deleteAllThreads()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
SignalDatabase.messages.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.messages.getConversation(senderThreadId, 0, 1).use {
|
||||
it.moveToFirst()
|
||||
it.requireLong(MessageTable.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.DirectReply(storyMessageId).serialize(), replyRecord.parentStoryId!!.serialize())
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.messages.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.messages.getStoryReplies(insertResult.get().messageId).use { cursor ->
|
||||
assertEquals(1, cursor.count)
|
||||
cursor.moveToFirst()
|
||||
cursor.requireLong(MessageTable.ID)
|
||||
}
|
||||
|
||||
val replyRecord = SignalDatabase.messages.getMessageRecord(replyId) as MediaMmsMessageRecord
|
||||
assertEquals(ParentStoryId.GroupReply(insertResult.get().messageId).serialize(), replyRecord.parentStoryId?.serialize())
|
||||
assertEquals(threadForGroup, replyRecord.threadId)
|
||||
assertEquals(expectedBody, replyRecord.body)
|
||||
|
||||
SignalDatabase.messages.deleteGroupStoryReplies(insertResult.get().messageId)
|
||||
SignalDatabase.messages.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.messages.getMessageRecord(1)
|
||||
val threadSize = SignalDatabase.messages.getMessageCountForThread(record.threadId)
|
||||
assertEquals(1, threadSize)
|
||||
|
||||
assertTrue(record.isSecure)
|
||||
assertEquals(expectedBody, record.body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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)))
|
||||
}
|
||||
|
||||
@Ignore("Flakey espresso test.")
|
||||
@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,139 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins
|
||||
import io.reactivex.rxjava3.schedulers.TestScheduler
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||
|
||||
class SafetyNumberBottomSheetRepositoryTest {
|
||||
|
||||
@get:Rule val harness = SignalActivityRule(othersCount = 10)
|
||||
|
||||
private val testScheduler = TestScheduler()
|
||||
private val subjectUnderTest = SafetyNumberBottomSheetRepository()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
RxJavaPlugins.setInitIoSchedulerHandler { testScheduler }
|
||||
RxJavaPlugins.setIoSchedulerHandler { testScheduler }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
|
||||
val recipients = harness.others
|
||||
val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
|
||||
|
||||
val result = subjectUnderTest.getBuckets(recipients, destinations).test()
|
||||
|
||||
testScheduler.triggerActions()
|
||||
|
||||
result.assertValueAt(0) { map ->
|
||||
assertMatch(map, mapOf(SafetyNumberBucket.ContactsBucket to harness.others))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() {
|
||||
// GIVEN
|
||||
val recipients = harness.others
|
||||
val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) }
|
||||
|
||||
// WHEN
|
||||
val result = subjectUnderTest.getBuckets(recipients, destination).test(1)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
// THEN
|
||||
result.assertValue { map ->
|
||||
assertMatch(map, mapOf(SafetyNumberBucket.ContactsBucket to harness.others.take(1)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIHaveADistributionListDestination_whenIGetBuckets_thenIOnlyHaveDistributionListDestinationWithCorrespondingMembers() {
|
||||
// GIVEN
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
|
||||
// WHEN
|
||||
val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
// THEN
|
||||
result.assertValue { map ->
|
||||
assertMatch(
|
||||
map,
|
||||
mapOf(
|
||||
SafetyNumberBucket.DistributionListBucket(distributionList, "ListA") to harness.others.take(5)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIHaveADistributionListDestinationAndIGetBuckets_whenIRemoveFromStories_thenIOnlyHaveDistributionListDestinationWithCorrespondingMembers() {
|
||||
// GIVEN
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val toRemove = distributionListMembers.last()
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
// WHEN
|
||||
subjectUnderTest.removeFromStories(toRemove, listOf(destinationKey)).subscribe()
|
||||
testSubscriber.request(1)
|
||||
testScheduler.triggerActions()
|
||||
testSubscriber.awaitCount(3)
|
||||
|
||||
// THEN
|
||||
testSubscriber.assertValueAt(2) { map ->
|
||||
assertMatch(
|
||||
map,
|
||||
mapOf(
|
||||
SafetyNumberBucket.DistributionListBucket(distributionList, "ListA") to distributionListMembers.dropLast(1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenIHaveADistributionListDestinationAndIGetBuckets_whenIRemoveAllFromStory_thenINoLongerHaveEntryForThatBucket() {
|
||||
// GIVEN
|
||||
val distributionListMembers = harness.others.take(5)
|
||||
val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!!
|
||||
val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!)
|
||||
val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2)
|
||||
testScheduler.triggerActions()
|
||||
|
||||
// WHEN
|
||||
subjectUnderTest.removeAllFromStory(distributionListMembers, distributionList).subscribe()
|
||||
testSubscriber.request(1)
|
||||
testScheduler.triggerActions()
|
||||
testSubscriber.awaitCount(3)
|
||||
|
||||
// THEN
|
||||
testSubscriber.assertValueAt(2) { map ->
|
||||
assertMatch(map, mapOf())
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertMatch(
|
||||
resultMap: Map<SafetyNumberBucket, List<SafetyNumberRecipient>>,
|
||||
idMap: Map<SafetyNumberBucket, List<RecipientId>>
|
||||
): Boolean {
|
||||
assertEquals("Result and ID Maps had different key sets", idMap.keys, resultMap.keys)
|
||||
|
||||
resultMap.forEach { (bucket, members) ->
|
||||
assertEquals("Mismatch in Bucket $bucket", idMap[bucket], members.map { it.recipient.id })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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
|
||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
|
||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
|
||||
import org.thoughtcrime.securesms.database.IdentityTable
|
||||
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
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.RegistrationData
|
||||
import org.thoughtcrime.securesms.registration.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.RegistrationUtil
|
||||
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
|
||||
|
||||
/**
|
||||
* Test rule to use that sets up the application in a mostly registered state. Enough so that most
|
||||
* activities should be launchable directly.
|
||||
*
|
||||
* To use: `@get:Rule val harness = SignalActivityRule()`
|
||||
*/
|
||||
class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource() {
|
||||
|
||||
val application: Application = ApplicationDependencies.getApplication()
|
||||
|
||||
lateinit var context: Context
|
||||
private set
|
||||
lateinit var self: Recipient
|
||||
private set
|
||||
lateinit var others: List<RecipientId>
|
||||
private set
|
||||
|
||||
override fun before() {
|
||||
context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
self = setupSelf()
|
||||
others = setupOthers()
|
||||
|
||||
InstrumentationApplicationDependencyProvider.clearHandlers()
|
||||
}
|
||||
|
||||
private fun setupSelf(): Recipient {
|
||||
DeviceTransferBlockingInterceptor.getInstance().blockNetwork()
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(application).edit().putBoolean("pref_prompted_push_registration", true).commit()
|
||||
val masterSecret = MasterSecretUtil.generateMasterSecret(application, MasterSecretUtil.UNENCRYPTED_PASSPHRASE)
|
||||
MasterSecretUtil.generateAsymmetricMasterSecret(application, masterSecret)
|
||||
val preferences: SharedPreferences = application.getSharedPreferences(MasterSecretUtil.PREFERENCES_NAME, 0)
|
||||
preferences.edit().putBoolean("passphrase_initialized", true).commit()
|
||||
|
||||
val registrationRepository = RegistrationRepository(application)
|
||||
|
||||
InstrumentationApplicationDependencyProvider.addMockWebRequestHandlers(Put("/v2/keys") { MockResponse().success() })
|
||||
val response: ServiceResponse<VerifyAccountResponse> = registrationRepository.registerAccountWithoutRegistrationLock(
|
||||
RegistrationData(
|
||||
code = "123123",
|
||||
e164 = "+15555550101",
|
||||
password = Util.getSecret(18),
|
||||
registrationId = registrationRepository.registrationId,
|
||||
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"))
|
||||
|
||||
return Recipient.self()
|
||||
}
|
||||
|
||||
private fun setupOthers(): List<RecipientId> {
|
||||
val others = mutableListOf<RecipientId>()
|
||||
|
||||
if (othersCount !in 0 until 1000) {
|
||||
throw IllegalArgumentException("$othersCount must be between 0 and 1000")
|
||||
}
|
||||
|
||||
for (i in 0 until othersCount) {
|
||||
val aci = ACI.from(UUID.randomUUID())
|
||||
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, 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
|
||||
}
|
||||
|
||||
return others
|
||||
}
|
||||
|
||||
inline fun <reified T : Activity> launchActivity(initIntent: Intent.() -> Unit = {}): ActivityScenario<T> {
|
||||
return androidx.test.core.app.launchActivity(Intent(context, T::class.java).apply(initIntent))
|
||||
}
|
||||
|
||||
fun changeIdentityKey(recipient: Recipient, identityKey: IdentityKey = IdentityKeyUtil.generateIdentityKeyPair().publicKey) {
|
||||
ApplicationDependencies.getProtocolStore().aci().saveIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0), identityKey)
|
||||
}
|
||||
|
||||
fun getIdentity(recipient: Recipient): IdentityKey {
|
||||
return ApplicationDependencies.getProtocolStore().aci().identities().getIdentity(SignalProtocolAddress(recipient.requireServiceId().toString(), 0))
|
||||
}
|
||||
|
||||
fun setVerified(recipient: Recipient, status: IdentityTable.VerifiedStatus) {
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipient.id, getIdentity(recipient), IdentityTable.VerifiedStatus.VERIFIED)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.testing
|
||||
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.whispersystems.signalservice.api.push.ACI
|
||||
import org.whispersystems.signalservice.api.push.PNI
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Sets up bare-minimum to allow writing unit tests against the database,
|
||||
* including setting up the local ACI and PNI pair.
|
||||
*
|
||||
* @param deleteAllThreadsOnEachRun Run deleteAllThreads between each unit test
|
||||
*/
|
||||
class SignalDatabaseRule(
|
||||
private val deleteAllThreadsOnEachRun: Boolean = true
|
||||
) : TestWatcher() {
|
||||
|
||||
val localAci: ACI = ACI.from(UUID.randomUUID())
|
||||
val localPni: PNI = PNI.from(UUID.randomUUID())
|
||||
|
||||
override fun starting(description: Description?) {
|
||||
deleteAllThreads()
|
||||
|
||||
SignalStore.account().setAci(localAci)
|
||||
SignalStore.account().setPni(localPni)
|
||||
}
|
||||
|
||||
override fun finished(description: Description?) {
|
||||
deleteAllThreads()
|
||||
}
|
||||
|
||||
private fun deleteAllThreads() {
|
||||
if (deleteAllThreadsOnEachRun) {
|
||||
SignalDatabase.messages.deleteAllThreads()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/signal_accent_green"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
4
app/src/instrumentation/res/values/strings.xml
Normal file
4
app/src/instrumentation/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<resources>
|
||||
<string name="app_name">Signal (Instrumentation)</string>
|
||||
</resources>
|
||||
@@ -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,19 +214,20 @@
|
||||
</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"
|
||||
android:host="addstickers" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -255,10 +258,11 @@
|
||||
</activity-alias>
|
||||
|
||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@style/Signal.Transparent">
|
||||
|
||||
<intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -275,7 +279,7 @@
|
||||
android:host="signal.group"/>
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -285,7 +289,7 @@
|
||||
android:host="signal.tube" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
@@ -328,11 +332,6 @@
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="adjustResize"/>
|
||||
|
||||
<activity android:name=".DatabaseMigrationActivity"
|
||||
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
|
||||
android:launchMode="singleTask"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".migrations.ApplicationMigrationActivity"
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:launchMode="singleTask"
|
||||
@@ -366,20 +365,32 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".mediasend.v2.MediaSelectionActivity"
|
||||
android:theme="@style/TextSecure.FullScreenMedia"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:launchMode="singleTop"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".conversation.mutiselect.forward.MultiselectForwardActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
|
||||
|
||||
<activity android:name=".mediasend.v2.stories.StoriesMultiselectForwardActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" />
|
||||
|
||||
<activity android:name=".PassphraseChangeActivity"
|
||||
android:label="@string/AndroidManifest__change_passphrase"
|
||||
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">
|
||||
@@ -406,7 +417,14 @@
|
||||
android:screenOrientation="portrait"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
android:theme="@style/TextSecure.DarkNoActionBar.StoryViewer"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||
android:parentActivityName=".MainActivity">
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.PARENT_ACTIVITY"
|
||||
android:value="org.thoughtcrime.securesms.MainActivity" />
|
||||
</activity>
|
||||
|
||||
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
@@ -464,6 +482,7 @@
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<activity android:name=".DeviceActivity"
|
||||
android:screenOrientation="portrait"
|
||||
android:label="@string/AndroidManifest__linked_devices"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
@@ -471,10 +490,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"
|
||||
@@ -499,10 +520,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" />
|
||||
@@ -521,6 +543,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"
|
||||
@@ -536,7 +559,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"
|
||||
@@ -552,6 +575,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" />
|
||||
@@ -635,13 +662,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"
|
||||
@@ -652,8 +685,18 @@
|
||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||
|
||||
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService"/>
|
||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||
<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:exported="false" android:name=".service.KeyCachingService"/>
|
||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||
<service android:name=".service.webrtc.AndroidCallConnectionService"
|
||||
@@ -664,13 +707,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>
|
||||
@@ -710,7 +753,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>
|
||||
@@ -801,51 +844,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.ForegroundServiceUtil$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>
|
||||
@@ -853,7 +898,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>
|
||||
@@ -881,16 +926,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>
|
||||
|
||||
@@ -1,848 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.camera.view;
|
||||
|
||||
import android.Manifest.permission;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.TypedArray;
|
||||
import android.hardware.display.DisplayManager;
|
||||
import android.hardware.display.DisplayManager.DisplayListener;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.Parcelable;
|
||||
import android.text.TextUtils;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.Display;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.ScaleGestureDetector;
|
||||
import android.view.Surface;
|
||||
import android.view.View;
|
||||
import android.view.ViewConfiguration;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.FrameLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
import androidx.annotation.RestrictTo;
|
||||
import androidx.annotation.RestrictTo.Scope;
|
||||
import androidx.camera.core.Camera;
|
||||
import androidx.camera.core.CameraSelector;
|
||||
import androidx.camera.core.FocusMeteringAction;
|
||||
import androidx.camera.core.FocusMeteringResult;
|
||||
import androidx.camera.core.ImageCapture;
|
||||
import androidx.camera.core.ImageCapture.OnImageCapturedCallback;
|
||||
import androidx.camera.core.ImageCapture.OnImageSavedCallback;
|
||||
import androidx.camera.core.ImageProxy;
|
||||
import androidx.camera.core.Logger;
|
||||
import androidx.camera.core.MeteringPoint;
|
||||
import androidx.camera.core.MeteringPointFactory;
|
||||
import androidx.camera.core.VideoCapture;
|
||||
import androidx.camera.core.VideoCapture.OnVideoSavedCallback;
|
||||
import androidx.camera.core.impl.LensFacingConverter;
|
||||
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||
import androidx.camera.core.impl.utils.futures.Futures;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* A {@link View} that displays a preview of the camera with methods {@link
|
||||
* #takePicture(Executor, OnImageCapturedCallback)},
|
||||
* {@link #takePicture(ImageCapture.OutputFileOptions, Executor, OnImageSavedCallback)},
|
||||
* {@link #startRecording(File , Executor , OnVideoSavedCallback callback)}
|
||||
* and {@link #stopRecording()}.
|
||||
*
|
||||
* <p>Because the Camera is a limited resource and consumes a high amount of power, CameraView must
|
||||
* be opened/closed. CameraView will handle opening/closing automatically through use of a {@link
|
||||
* LifecycleOwner}. Use {@link #bindToLifecycle(LifecycleOwner)} to start the camera.
|
||||
*/
|
||||
@RequiresApi(21)
|
||||
@SuppressLint("RestrictedApi")
|
||||
public final class SignalCameraView extends FrameLayout {
|
||||
static final String TAG = Log.tag(SignalCameraView.class);
|
||||
|
||||
static final int INDEFINITE_VIDEO_DURATION = -1;
|
||||
static final int INDEFINITE_VIDEO_SIZE = -1;
|
||||
|
||||
private static final String EXTRA_SUPER = "super";
|
||||
private static final String EXTRA_ZOOM_RATIO = "zoom_ratio";
|
||||
private static final String EXTRA_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled";
|
||||
private static final String EXTRA_FLASH = "flash";
|
||||
private static final String EXTRA_MAX_VIDEO_DURATION = "max_video_duration";
|
||||
private static final String EXTRA_MAX_VIDEO_SIZE = "max_video_size";
|
||||
private static final String EXTRA_SCALE_TYPE = "scale_type";
|
||||
private static final String EXTRA_CAMERA_DIRECTION = "camera_direction";
|
||||
private static final String EXTRA_CAPTURE_MODE = "captureMode";
|
||||
|
||||
private static final int LENS_FACING_NONE = 0;
|
||||
private static final int LENS_FACING_FRONT = 1;
|
||||
private static final int LENS_FACING_BACK = 2;
|
||||
private static final int FLASH_MODE_AUTO = 1;
|
||||
private static final int FLASH_MODE_ON = 2;
|
||||
private static final int FLASH_MODE_OFF = 4;
|
||||
// For tap-to-focus
|
||||
private long mDownEventTimestamp;
|
||||
// For pinch-to-zoom
|
||||
private PinchToZoomGestureDetector mPinchToZoomGestureDetector;
|
||||
private boolean mIsPinchToZoomEnabled = true;
|
||||
SignalCameraXModule mCameraModule;
|
||||
private final DisplayManager.DisplayListener mDisplayListener =
|
||||
new DisplayListener() {
|
||||
@Override
|
||||
public void onDisplayAdded(int displayId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayRemoved(int displayId) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisplayChanged(int displayId) {
|
||||
mCameraModule.invalidateView();
|
||||
}
|
||||
};
|
||||
private PreviewView mPreviewView;
|
||||
// For accessibility event
|
||||
private MotionEvent mUpEvent;
|
||||
|
||||
// BEGIN Custom Signal Code Block
|
||||
private Consumer<Throwable> errorConsumer;
|
||||
private Throwable pendingError;
|
||||
// END Custom Signal Code Block
|
||||
|
||||
public SignalCameraView(@NonNull Context context) {
|
||||
this(context, null);
|
||||
}
|
||||
|
||||
public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs) {
|
||||
this(context, attrs, 0);
|
||||
}
|
||||
|
||||
public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
|
||||
super(context, attrs, defStyle);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
@RequiresApi(21)
|
||||
public SignalCameraView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr,
|
||||
int defStyleRes) {
|
||||
super(context, attrs, defStyleAttr, defStyleRes);
|
||||
init(context, attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds control of the camera used by this view to the given lifecycle.
|
||||
*
|
||||
* <p>This links opening/closing the camera to the given lifecycle. The camera will not operate
|
||||
* unless this method is called with a valid {@link LifecycleOwner} that is not in the {@link
|
||||
* androidx.lifecycle.Lifecycle.State#DESTROYED} state. Call this method only once camera
|
||||
* permissions have been obtained.
|
||||
*
|
||||
* <p>Once the provided lifecycle has transitioned to a {@link
|
||||
* androidx.lifecycle.Lifecycle.State#DESTROYED} state, CameraView must be bound to a new
|
||||
* lifecycle through this method in order to operate the camera.
|
||||
*
|
||||
* @param lifecycleOwner The lifecycle that will control this view's camera
|
||||
* @throws IllegalArgumentException if provided lifecycle is in a {@link
|
||||
* androidx.lifecycle.Lifecycle.State#DESTROYED} state.
|
||||
* @throws IllegalStateException if camera permissions are not granted.
|
||||
*/
|
||||
// BEGIN Custom Signal Code Block
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner, Consumer<Throwable> errorConsumer) {
|
||||
mCameraModule.bindToLifecycle(lifecycleOwner);
|
||||
this.errorConsumer = errorConsumer;
|
||||
if (pendingError != null) {
|
||||
errorConsumer.accept(pendingError);
|
||||
}
|
||||
}
|
||||
// END Custom Signal Code Block
|
||||
|
||||
|
||||
private void init(Context context, @Nullable AttributeSet attrs) {
|
||||
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
|
||||
|
||||
// Begin custom signal code block
|
||||
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
|
||||
mCameraModule = new SignalCameraXModule(this, error -> {
|
||||
if (errorConsumer != null) {
|
||||
errorConsumer.accept(error);
|
||||
} else {
|
||||
pendingError = error;
|
||||
}
|
||||
});
|
||||
// End custom signal code block
|
||||
|
||||
if (attrs != null) {
|
||||
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
|
||||
setScaleType(
|
||||
PreviewView.ScaleType.fromId(
|
||||
a.getInteger(R.styleable.CameraView_scaleType,
|
||||
getScaleType().getId())));
|
||||
setPinchToZoomEnabled(
|
||||
a.getBoolean(
|
||||
R.styleable.CameraView_pinchToZoomEnabled, isPinchToZoomEnabled()));
|
||||
setCaptureMode(
|
||||
CaptureMode.fromId(
|
||||
a.getInteger(R.styleable.CameraView_captureMode,
|
||||
getCaptureMode().getId())));
|
||||
|
||||
int lensFacing = a.getInt(R.styleable.CameraView_lensFacing, LENS_FACING_BACK);
|
||||
switch (lensFacing) {
|
||||
case LENS_FACING_NONE:
|
||||
setCameraLensFacing(null);
|
||||
break;
|
||||
case LENS_FACING_FRONT:
|
||||
setCameraLensFacing(CameraSelector.LENS_FACING_FRONT);
|
||||
break;
|
||||
case LENS_FACING_BACK:
|
||||
setCameraLensFacing(CameraSelector.LENS_FACING_BACK);
|
||||
break;
|
||||
default:
|
||||
// Unhandled event.
|
||||
}
|
||||
|
||||
int flashMode = a.getInt(R.styleable.CameraView_flash, 0);
|
||||
switch (flashMode) {
|
||||
case FLASH_MODE_AUTO:
|
||||
setFlash(ImageCapture.FLASH_MODE_AUTO);
|
||||
break;
|
||||
case FLASH_MODE_ON:
|
||||
setFlash(ImageCapture.FLASH_MODE_ON);
|
||||
break;
|
||||
case FLASH_MODE_OFF:
|
||||
setFlash(ImageCapture.FLASH_MODE_OFF);
|
||||
break;
|
||||
default:
|
||||
// Unhandled event.
|
||||
}
|
||||
|
||||
a.recycle();
|
||||
}
|
||||
|
||||
if (getBackground() == null) {
|
||||
setBackgroundColor(0xFF111111);
|
||||
}
|
||||
|
||||
mPinchToZoomGestureDetector = new PinchToZoomGestureDetector(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
protected LayoutParams generateDefaultLayoutParams() {
|
||||
return new LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNull
|
||||
protected Parcelable onSaveInstanceState() {
|
||||
// TODO(b/113884082): Decide what belongs here or what should be invalidated on
|
||||
// configuration
|
||||
// change
|
||||
Bundle state = new Bundle();
|
||||
state.putParcelable(EXTRA_SUPER, super.onSaveInstanceState());
|
||||
state.putInt(EXTRA_SCALE_TYPE, getScaleType().getId());
|
||||
state.putFloat(EXTRA_ZOOM_RATIO, getZoomRatio());
|
||||
state.putBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED, isPinchToZoomEnabled());
|
||||
state.putString(EXTRA_FLASH, FlashModeConverter.nameOf(getFlash()));
|
||||
state.putLong(EXTRA_MAX_VIDEO_DURATION, getMaxVideoDuration());
|
||||
state.putLong(EXTRA_MAX_VIDEO_SIZE, getMaxVideoSize());
|
||||
if (getCameraLensFacing() != null) {
|
||||
state.putString(EXTRA_CAMERA_DIRECTION,
|
||||
LensFacingConverter.nameOf(getCameraLensFacing()));
|
||||
}
|
||||
state.putInt(EXTRA_CAPTURE_MODE, getCaptureMode().getId());
|
||||
return state;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRestoreInstanceState(@Nullable Parcelable savedState) {
|
||||
// TODO(b/113884082): Decide what belongs here or what should be invalidated on
|
||||
// configuration
|
||||
// change
|
||||
if (savedState instanceof Bundle) {
|
||||
Bundle state = (Bundle) savedState;
|
||||
super.onRestoreInstanceState(state.getParcelable(EXTRA_SUPER));
|
||||
setScaleType(PreviewView.ScaleType.fromId(state.getInt(EXTRA_SCALE_TYPE)));
|
||||
setZoomRatio(state.getFloat(EXTRA_ZOOM_RATIO));
|
||||
setPinchToZoomEnabled(state.getBoolean(EXTRA_PINCH_TO_ZOOM_ENABLED));
|
||||
setFlash(FlashModeConverter.valueOf(state.getString(EXTRA_FLASH)));
|
||||
setMaxVideoDuration(state.getLong(EXTRA_MAX_VIDEO_DURATION));
|
||||
setMaxVideoSize(state.getLong(EXTRA_MAX_VIDEO_SIZE));
|
||||
String lensFacingString = state.getString(EXTRA_CAMERA_DIRECTION);
|
||||
setCameraLensFacing(
|
||||
TextUtils.isEmpty(lensFacingString)
|
||||
? null
|
||||
: LensFacingConverter.valueOf(lensFacingString));
|
||||
setCaptureMode(CaptureMode.fromId(state.getInt(EXTRA_CAPTURE_MODE)));
|
||||
} else {
|
||||
super.onRestoreInstanceState(savedState);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onAttachedToWindow() {
|
||||
super.onAttachedToWindow();
|
||||
DisplayManager dpyMgr =
|
||||
(DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
dpyMgr.registerDisplayListener(mDisplayListener, new Handler(Looper.getMainLooper()));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDetachedFromWindow() {
|
||||
super.onDetachedFromWindow();
|
||||
DisplayManager dpyMgr =
|
||||
(DisplayManager) getContext().getSystemService(Context.DISPLAY_SERVICE);
|
||||
dpyMgr.unregisterDisplayListener(mDisplayListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the {@link LiveData} of the underlying {@link PreviewView}'s
|
||||
* {@link PreviewView.StreamState}.
|
||||
*
|
||||
* @return A {@link LiveData} containing the {@link PreviewView.StreamState}. Apps can either
|
||||
* get current value by {@link LiveData#getValue()} or register a observer by
|
||||
* {@link LiveData#observe}.
|
||||
* @see PreviewView#getPreviewStreamState()
|
||||
*/
|
||||
@NonNull
|
||||
public LiveData<PreviewView.StreamState> getPreviewStreamState() {
|
||||
return mPreviewView.getPreviewStreamState();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
PreviewView getPreviewView() {
|
||||
return mPreviewView;
|
||||
}
|
||||
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
@Override
|
||||
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||
// Since bindToLifecycle will depend on the measured dimension, only call it when measured
|
||||
// dimension is not 0x0
|
||||
if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) {
|
||||
mCameraModule.bindToLifecycleAfterViewMeasured();
|
||||
}
|
||||
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
|
||||
}
|
||||
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
@Override
|
||||
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
|
||||
// In case that the CameraView size is always set as 0x0, we still need to trigger to force
|
||||
// binding to lifecycle
|
||||
mCameraModule.bindToLifecycleAfterViewMeasured();
|
||||
|
||||
mCameraModule.invalidateView();
|
||||
super.onLayout(changed, left, top, right, bottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return One of {@link Surface#ROTATION_0}, {@link Surface#ROTATION_90}, {@link
|
||||
* Surface#ROTATION_180}, {@link Surface#ROTATION_270}.
|
||||
*/
|
||||
int getDisplaySurfaceRotation() {
|
||||
Display display = getDisplay();
|
||||
|
||||
// Null when the View is detached. If we were in the middle of a background operation,
|
||||
// better to not NPE. When the background operation finishes, it'll realize that the camera
|
||||
// was closed.
|
||||
if (display == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return display.getRotation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scale type used to scale the preview.
|
||||
*
|
||||
* @return The current {@link PreviewView.ScaleType}.
|
||||
*/
|
||||
@NonNull
|
||||
public PreviewView.ScaleType getScaleType() {
|
||||
return mPreviewView.getScaleType();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the view finder scale type.
|
||||
*
|
||||
* <p>This controls how the view finder should be scaled and positioned within the view.
|
||||
*
|
||||
* @param scaleType The desired {@link PreviewView.ScaleType}.
|
||||
*/
|
||||
public void setScaleType(@NonNull PreviewView.ScaleType scaleType) {
|
||||
mPreviewView.setScaleType(scaleType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scale type used to scale the preview.
|
||||
*
|
||||
* @return The current {@link CaptureMode}.
|
||||
*/
|
||||
@NonNull
|
||||
public CaptureMode getCaptureMode() {
|
||||
return mCameraModule.getCaptureMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the CameraView capture mode
|
||||
*
|
||||
* <p>This controls only image or video capture function is enabled or both are enabled.
|
||||
*
|
||||
* @param captureMode The desired {@link CaptureMode}.
|
||||
*/
|
||||
public void setCaptureMode(@NonNull CaptureMode captureMode) {
|
||||
mCameraModule.setCaptureMode(captureMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum duration of videos, or {@link #INDEFINITE_VIDEO_DURATION} if there is no
|
||||
* timeout.
|
||||
*
|
||||
* @hide Not currently implemented.
|
||||
*/
|
||||
@RestrictTo(Scope.LIBRARY_GROUP)
|
||||
public long getMaxVideoDuration() {
|
||||
return mCameraModule.getMaxVideoDuration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum video duration before
|
||||
* {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)} is called
|
||||
* automatically.
|
||||
* Use {@link #INDEFINITE_VIDEO_DURATION} to disable the timeout.
|
||||
*/
|
||||
private void setMaxVideoDuration(long duration) {
|
||||
mCameraModule.setMaxVideoDuration(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum size of videos in bytes, or {@link #INDEFINITE_VIDEO_SIZE} if there is no
|
||||
* timeout.
|
||||
*/
|
||||
private long getMaxVideoSize() {
|
||||
return mCameraModule.getMaxVideoSize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum video size in bytes before
|
||||
* {@link OnVideoSavedCallback#onVideoSaved(VideoCapture.OutputFileResults)}
|
||||
* is called automatically. Use {@link #INDEFINITE_VIDEO_SIZE} to disable the size restriction.
|
||||
*/
|
||||
private void setMaxVideoSize(long size) {
|
||||
mCameraModule.setMaxVideoSize(size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a picture, and calls {@link OnImageCapturedCallback#onCaptureSuccess(ImageProxy)}
|
||||
* once when done.
|
||||
*
|
||||
* @param executor The executor in which the callback methods will be run.
|
||||
* @param callback Callback which will receive success or failure callbacks.
|
||||
*/
|
||||
public void takePicture(@NonNull Executor executor, @NonNull OnImageCapturedCallback callback) {
|
||||
mCameraModule.takePicture(executor, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a picture and calls
|
||||
* {@link OnImageSavedCallback#onImageSaved(ImageCapture.OutputFileResults)} when done.
|
||||
*
|
||||
* <p> The value of {@link ImageCapture.Metadata#isReversedHorizontal()} in the
|
||||
* {@link ImageCapture.OutputFileOptions} will be overwritten based on camera direction. For
|
||||
* front camera, it will be set to true; for back camera, it will be set to false.
|
||||
*
|
||||
* @param outputFileOptions Options to store the newly captured image.
|
||||
* @param executor The executor in which the callback methods will be run.
|
||||
* @param callback Callback which will receive success or failure.
|
||||
*/
|
||||
public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions,
|
||||
@NonNull Executor executor,
|
||||
@NonNull OnImageSavedCallback callback) {
|
||||
mCameraModule.takePicture(outputFileOptions, executor, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a video and calls the OnVideoSavedCallback when done.
|
||||
*
|
||||
* @param outputFileOptions Options to store the newly captured video.
|
||||
* @param executor The executor in which the callback methods will be run.
|
||||
* @param callback Callback which will receive success or failure.
|
||||
*/
|
||||
public void startRecording(@NonNull VideoCapture.OutputFileOptions outputFileOptions,
|
||||
@NonNull Executor executor,
|
||||
@NonNull OnVideoSavedCallback callback) {
|
||||
mCameraModule.startRecording(outputFileOptions, executor, callback);
|
||||
}
|
||||
|
||||
/** Stops an in progress video. */
|
||||
public void stopRecording() {
|
||||
mCameraModule.stopRecording();
|
||||
}
|
||||
|
||||
/** @return True if currently recording. */
|
||||
public boolean isRecording() {
|
||||
return mCameraModule.isRecording();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries whether the current device has a camera with the specified direction.
|
||||
*
|
||||
* @return True if the device supports the direction.
|
||||
* @throws IllegalStateException if the CAMERA permission is not currently granted.
|
||||
*/
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) {
|
||||
return mCameraModule.hasCameraWithLensFacing(lensFacing);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles between the primary front facing camera and the primary back facing camera.
|
||||
*
|
||||
* <p>This will have no effect if not already bound to a lifecycle via {@link
|
||||
* #bindToLifecycle(LifecycleOwner)}.
|
||||
*/
|
||||
public void toggleCamera() {
|
||||
mCameraModule.toggleCamera();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the desired camera by specifying desired lensFacing.
|
||||
*
|
||||
* <p>This will choose the primary camera with the specified camera lensFacing.
|
||||
*
|
||||
* <p>If called before {@link #bindToLifecycle(LifecycleOwner)}, this will set the camera to be
|
||||
* used when first bound to the lifecycle. If the specified lensFacing is not supported by the
|
||||
* device, as determined by {@link #hasCameraWithLensFacing(int)}, the first supported
|
||||
* lensFacing will be chosen when {@link #bindToLifecycle(LifecycleOwner)} is called.
|
||||
*
|
||||
* <p>If called with {@code null} AFTER binding to the lifecycle, the behavior would be
|
||||
* equivalent to unbind the use cases without the lifecycle having to be destroyed.
|
||||
*
|
||||
* @param lensFacing The desired camera lensFacing.
|
||||
*/
|
||||
public void setCameraLensFacing(@Nullable Integer lensFacing) {
|
||||
mCameraModule.setCameraLensFacing(lensFacing);
|
||||
}
|
||||
|
||||
/** Returns the currently selected lensFacing. */
|
||||
@Nullable
|
||||
public Integer getCameraLensFacing() {
|
||||
return mCameraModule.getLensFacing();
|
||||
}
|
||||
|
||||
/** Gets the active flash strategy. */
|
||||
@ImageCapture.FlashMode
|
||||
public int getFlash() {
|
||||
return mCameraModule.getFlash();
|
||||
}
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
public boolean hasFlash() {
|
||||
return mCameraModule.hasFlash();
|
||||
}
|
||||
// End Signal Custom Code Block
|
||||
|
||||
/** Sets the active flash strategy. */
|
||||
public void setFlash(@ImageCapture.FlashMode int flashMode) {
|
||||
mCameraModule.setFlash(flashMode);
|
||||
}
|
||||
|
||||
private long delta() {
|
||||
return System.currentTimeMillis() - mDownEventTimestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onTouchEvent(@NonNull MotionEvent event) {
|
||||
// Disable pinch-to-zoom and tap-to-focus while the camera module is paused.
|
||||
if (mCameraModule.isPaused()) {
|
||||
return false;
|
||||
}
|
||||
// Only forward the event to the pinch-to-zoom gesture detector when pinch-to-zoom is
|
||||
// enabled.
|
||||
if (isPinchToZoomEnabled()) {
|
||||
mPinchToZoomGestureDetector.onTouchEvent(event);
|
||||
}
|
||||
if (event.getPointerCount() == 2 && isPinchToZoomEnabled() && isZoomSupported()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Camera focus
|
||||
switch (event.getAction()) {
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
mDownEventTimestamp = System.currentTimeMillis();
|
||||
break;
|
||||
case MotionEvent.ACTION_UP:
|
||||
if (delta() < ViewConfiguration.getLongPressTimeout()
|
||||
&& mCameraModule.isBoundToLifecycle()) {
|
||||
mUpEvent = event;
|
||||
performClick();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// Unhandled event.
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the position of the touch event, or focus the center of the preview for
|
||||
* accessibility events
|
||||
*/
|
||||
@Override
|
||||
public boolean performClick() {
|
||||
super.performClick();
|
||||
|
||||
final float x = (mUpEvent != null) ? mUpEvent.getX() : getX() + getWidth() / 2f;
|
||||
final float y = (mUpEvent != null) ? mUpEvent.getY() : getY() + getHeight() / 2f;
|
||||
mUpEvent = null;
|
||||
|
||||
Camera camera = mCameraModule.getCamera();
|
||||
if (camera != null) {
|
||||
MeteringPointFactory pointFactory = mPreviewView.getMeteringPointFactory();
|
||||
float afPointWidth = 1.0f / 6.0f; // 1/6 total area
|
||||
float aePointWidth = afPointWidth * 1.5f;
|
||||
MeteringPoint afPoint = pointFactory.createPoint(x, y, afPointWidth);
|
||||
MeteringPoint aePoint = pointFactory.createPoint(x, y, aePointWidth);
|
||||
|
||||
ListenableFuture<FocusMeteringResult> future =
|
||||
camera.getCameraControl().startFocusAndMetering(
|
||||
new FocusMeteringAction.Builder(afPoint,
|
||||
FocusMeteringAction.FLAG_AF).addPoint(aePoint,
|
||||
FocusMeteringAction.FLAG_AE).build());
|
||||
Futures.addCallback(future, new FutureCallback<FocusMeteringResult>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable FocusMeteringResult result) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
// Throw the unexpected error.
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
}, CameraXExecutors.directExecutor());
|
||||
|
||||
} else {
|
||||
Logger.d(TAG, "cannot access camera");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
float rangeLimit(float val, float max, float min) {
|
||||
return Math.min(Math.max(val, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the view allows pinch-to-zoom.
|
||||
*
|
||||
* @return True if pinch to zoom is enabled.
|
||||
*/
|
||||
public boolean isPinchToZoomEnabled() {
|
||||
return mIsPinchToZoomEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether the view should allow pinch-to-zoom.
|
||||
*
|
||||
* <p>When enabled, the user can pinch the camera to zoom in/out. This only has an effect if the
|
||||
* bound camera supports zoom.
|
||||
*
|
||||
* @param enabled True to enable pinch-to-zoom.
|
||||
*/
|
||||
public void setPinchToZoomEnabled(boolean enabled) {
|
||||
mIsPinchToZoomEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current zoom ratio.
|
||||
*
|
||||
* @return The current zoom ratio.
|
||||
*/
|
||||
public float getZoomRatio() {
|
||||
return mCameraModule.getZoomRatio();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current zoom ratio.
|
||||
*
|
||||
* <p>Valid zoom values range from {@link #getMinZoomRatio()} to {@link #getMaxZoomRatio()}.
|
||||
*
|
||||
* @param zoomRatio The requested zoom ratio.
|
||||
*/
|
||||
public void setZoomRatio(float zoomRatio) {
|
||||
mCameraModule.setZoomRatio(zoomRatio);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimum zoom ratio.
|
||||
*
|
||||
* <p>For most cameras this should return a zoom ratio of 1. A zoom ratio of 1 corresponds to a
|
||||
* non-zoomed image.
|
||||
*
|
||||
* @return The minimum zoom ratio.
|
||||
*/
|
||||
public float getMinZoomRatio() {
|
||||
return mCameraModule.getMinZoomRatio();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the maximum zoom ratio.
|
||||
*
|
||||
* <p>The zoom ratio corresponds to the ratio between both the widths and heights of a
|
||||
* non-zoomed image and a maximally zoomed image for the selected camera.
|
||||
*
|
||||
* @return The maximum zoom ratio.
|
||||
*/
|
||||
public float getMaxZoomRatio() {
|
||||
return mCameraModule.getMaxZoomRatio();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the bound camera supports zooming.
|
||||
*
|
||||
* @return True if the camera supports zooming.
|
||||
*/
|
||||
public boolean isZoomSupported() {
|
||||
return mCameraModule.isZoomSupported();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on/off torch.
|
||||
*
|
||||
* @param torch True to turn on torch, false to turn off torch.
|
||||
*/
|
||||
public void enableTorch(boolean torch) {
|
||||
mCameraModule.enableTorch(torch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current torch status.
|
||||
*
|
||||
* @return true if torch is on , otherwise false
|
||||
*/
|
||||
public boolean isTorchOn() {
|
||||
return mCameraModule.isTorchOn();
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture mode used by CameraView.
|
||||
*
|
||||
* <p>This enum can be used to determine which capture mode will be enabled for {@link
|
||||
* SignalCameraView}.
|
||||
*/
|
||||
public enum CaptureMode {
|
||||
/** A mode where image capture is enabled. */
|
||||
IMAGE(0),
|
||||
/** A mode where video capture is enabled. */
|
||||
VIDEO(1),
|
||||
/**
|
||||
* A mode where both image capture and video capture are simultaneously enabled. Note that
|
||||
* this mode may not be available on every device.
|
||||
*/
|
||||
MIXED(2);
|
||||
|
||||
private final int mId;
|
||||
|
||||
int getId() {
|
||||
return mId;
|
||||
}
|
||||
|
||||
CaptureMode(int id) {
|
||||
mId = id;
|
||||
}
|
||||
|
||||
static CaptureMode fromId(int id) {
|
||||
for (CaptureMode f : values()) {
|
||||
if (f.mId == id) {
|
||||
return f;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
}
|
||||
|
||||
static class S extends ScaleGestureDetector.SimpleOnScaleGestureListener {
|
||||
private ScaleGestureDetector.OnScaleGestureListener mListener;
|
||||
|
||||
void setRealGestureDetector(ScaleGestureDetector.OnScaleGestureListener l) {
|
||||
mListener = l;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScale(ScaleGestureDetector detector) {
|
||||
return mListener.onScale(detector);
|
||||
}
|
||||
}
|
||||
|
||||
private class PinchToZoomGestureDetector extends ScaleGestureDetector
|
||||
implements ScaleGestureDetector.OnScaleGestureListener {
|
||||
PinchToZoomGestureDetector(Context context) {
|
||||
this(context, new S());
|
||||
}
|
||||
|
||||
PinchToZoomGestureDetector(Context context, S s) {
|
||||
super(context, s);
|
||||
s.setRealGestureDetector(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScale(ScaleGestureDetector detector) {
|
||||
float scale = detector.getScaleFactor();
|
||||
|
||||
// Speeding up the zoom by 2X.
|
||||
if (scale > 1f) {
|
||||
scale = 1.0f + (scale - 1.0f) * 2;
|
||||
} else {
|
||||
scale = 1.0f - (1.0f - scale) * 2;
|
||||
}
|
||||
|
||||
float newRatio = getZoomRatio() * scale;
|
||||
newRatio = rangeLimit(newRatio, getMaxZoomRatio(), getMinZoomRatio());
|
||||
setZoomRatio(newRatio);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScaleBegin(ScaleGestureDetector detector) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScaleEnd(ScaleGestureDetector detector) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,699 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2019 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package androidx.camera.view;
|
||||
|
||||
import android.Manifest.permission;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.res.Resources;
|
||||
import android.util.Rational;
|
||||
import android.util.Size;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.annotation.RequiresPermission;
|
||||
import androidx.camera.core.Camera;
|
||||
import androidx.camera.core.CameraInfoUnavailableException;
|
||||
import androidx.camera.core.CameraSelector;
|
||||
import androidx.camera.core.ImageCapture;
|
||||
import androidx.camera.core.ImageCapture.OnImageCapturedCallback;
|
||||
import androidx.camera.core.ImageCapture.OnImageSavedCallback;
|
||||
import androidx.camera.core.Logger;
|
||||
import androidx.camera.core.Preview;
|
||||
import androidx.camera.core.TorchState;
|
||||
import androidx.camera.core.UseCase;
|
||||
import androidx.camera.core.VideoCapture;
|
||||
import androidx.camera.core.VideoCapture.OnVideoSavedCallback;
|
||||
import androidx.camera.core.impl.CameraInternal;
|
||||
import androidx.camera.core.impl.LensFacingConverter;
|
||||
import androidx.camera.core.impl.utils.CameraOrientationUtil;
|
||||
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||
import androidx.camera.core.impl.utils.futures.Futures;
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.core.util.Preconditions;
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.lifecycle.LifecycleObserver;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.OnLifecycleEvent;
|
||||
|
||||
import com.google.common.util.concurrent.ListenableFuture;
|
||||
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints;
|
||||
import org.thoughtcrime.securesms.video.VideoUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static androidx.camera.core.ImageCapture.FLASH_MODE_OFF;
|
||||
|
||||
/** CameraX use case operation built on @{link androidx.camera.core}. */
|
||||
@RequiresApi(21)
|
||||
@SuppressLint("RestrictedApi")
|
||||
final class SignalCameraXModule {
|
||||
public static final String TAG = "CameraXModule";
|
||||
|
||||
private static final float UNITY_ZOOM_SCALE = 1f;
|
||||
private static final float ZOOM_NOT_SUPPORTED = UNITY_ZOOM_SCALE;
|
||||
private static final Rational ASPECT_RATIO_16_9 = new Rational(16, 9);
|
||||
private static final Rational ASPECT_RATIO_4_3 = new Rational(4, 3);
|
||||
private static final Rational ASPECT_RATIO_9_16 = new Rational(9, 16);
|
||||
private static final Rational ASPECT_RATIO_3_4 = new Rational(3, 4);
|
||||
|
||||
private final Preview.Builder mPreviewBuilder;
|
||||
private final VideoCapture.Builder mVideoCaptureBuilder;
|
||||
private final ImageCapture.Builder mImageCaptureBuilder;
|
||||
private final SignalCameraView mCameraView;
|
||||
final AtomicBoolean mVideoIsRecording = new AtomicBoolean(false);
|
||||
private SignalCameraView.CaptureMode mCaptureMode = SignalCameraView.CaptureMode.IMAGE;
|
||||
private long mMaxVideoDuration = SignalCameraView.INDEFINITE_VIDEO_DURATION;
|
||||
private long mMaxVideoSize = SignalCameraView.INDEFINITE_VIDEO_SIZE;
|
||||
@ImageCapture.FlashMode
|
||||
private int mFlash = FLASH_MODE_OFF;
|
||||
@Nullable
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
Camera mCamera;
|
||||
@Nullable
|
||||
private ImageCapture mImageCapture;
|
||||
@Nullable
|
||||
private VideoCapture mVideoCapture;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@Nullable
|
||||
Preview mPreview;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@Nullable
|
||||
LifecycleOwner mCurrentLifecycle;
|
||||
private final LifecycleObserver mCurrentLifecycleObserver =
|
||||
new LifecycleObserver() {
|
||||
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
|
||||
public void onDestroy(LifecycleOwner owner) {
|
||||
if (owner == mCurrentLifecycle) {
|
||||
clearCurrentLifecycle();
|
||||
}
|
||||
}
|
||||
};
|
||||
@Nullable
|
||||
private LifecycleOwner mNewLifecycle;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@Nullable
|
||||
Integer mCameraLensFacing = CameraSelector.LENS_FACING_BACK;
|
||||
@SuppressWarnings("WeakerAccess") /* synthetic accessor */
|
||||
@Nullable
|
||||
ProcessCameraProvider mCameraProvider;
|
||||
|
||||
// BEGIN Custom Signal Code Block
|
||||
SignalCameraXModule(SignalCameraView view, Consumer<Throwable> errorConsumer) {
|
||||
// END Custom Signal Code Block
|
||||
mCameraView = view;
|
||||
|
||||
Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()),
|
||||
new FutureCallback<ProcessCameraProvider>() {
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
@Override
|
||||
public void onSuccess(@Nullable ProcessCameraProvider provider) {
|
||||
Preconditions.checkNotNull(provider);
|
||||
mCameraProvider = provider;
|
||||
if (mCurrentLifecycle != null) {
|
||||
bindToLifecycle(mCurrentLifecycle);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
// BEGIN Custom Signal Code Block
|
||||
errorConsumer.accept(t);
|
||||
// END Custom Signal Code Block
|
||||
}
|
||||
}, CameraXExecutors.mainThreadExecutor());
|
||||
|
||||
mPreviewBuilder = new Preview.Builder().setTargetName("Preview");
|
||||
|
||||
mImageCaptureBuilder = new ImageCapture.Builder().setTargetName("ImageCapture");
|
||||
|
||||
mVideoCaptureBuilder = new VideoCapture.Builder().setTargetName("VideoCapture")
|
||||
.setAudioBitRate(VideoUtil.AUDIO_BIT_RATE)
|
||||
.setVideoFrameRate(VideoUtil.VIDEO_FRAME_RATE)
|
||||
.setBitRate(VideoUtil.VIDEO_BIT_RATE);
|
||||
}
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
void bindToLifecycle(LifecycleOwner lifecycleOwner) {
|
||||
mNewLifecycle = lifecycleOwner;
|
||||
|
||||
if (getMeasuredWidth() > 0 && getMeasuredHeight() > 0) {
|
||||
bindToLifecycleAfterViewMeasured();
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
void bindToLifecycleAfterViewMeasured() {
|
||||
if (mNewLifecycle == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearCurrentLifecycle();
|
||||
if (mNewLifecycle.getLifecycle().getCurrentState() == Lifecycle.State.DESTROYED) {
|
||||
// Lifecycle is already in a destroyed state. Since it may have been a valid
|
||||
// lifecycle when bound, but became destroyed while waiting for layout, treat this as
|
||||
// a no-op now that we have cleared the previous lifecycle.
|
||||
mNewLifecycle = null;
|
||||
return;
|
||||
}
|
||||
mCurrentLifecycle = mNewLifecycle;
|
||||
mNewLifecycle = null;
|
||||
|
||||
if (mCameraProvider == null) {
|
||||
// try again once the camera provider is no longer null
|
||||
return;
|
||||
}
|
||||
|
||||
Set<Integer> available = getAvailableCameraLensFacing();
|
||||
|
||||
if (available.isEmpty()) {
|
||||
Logger.w(TAG, "Unable to bindToLifeCycle since no cameras available");
|
||||
mCameraLensFacing = null;
|
||||
}
|
||||
|
||||
// Ensure the current camera exists, or default to another camera
|
||||
if (mCameraLensFacing != null && !available.contains(mCameraLensFacing)) {
|
||||
Logger.w(TAG, "Camera does not exist with direction " + mCameraLensFacing);
|
||||
|
||||
// Default to the first available camera direction
|
||||
mCameraLensFacing = available.iterator().next();
|
||||
|
||||
Logger.w(TAG, "Defaulting to primary camera with direction " + mCameraLensFacing);
|
||||
}
|
||||
|
||||
// Do not attempt to create use cases for a null cameraLensFacing. This could occur if
|
||||
// the user explicitly sets the LensFacing to null, or if we determined there
|
||||
// were no available cameras, which should be logged in the logic above.
|
||||
if (mCameraLensFacing == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the preferred aspect ratio as 4:3 if it is IMAGE only mode. Set the preferred aspect
|
||||
// ratio as 16:9 if it is VIDEO or MIXED mode. Then, it will be WYSIWYG when the view finder
|
||||
// is in CENTER_INSIDE mode.
|
||||
|
||||
boolean isDisplayPortrait = getDisplayRotationDegrees() == 0
|
||||
|| getDisplayRotationDegrees() == 180;
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
int resolution = CameraXUtil.getIdealResolution(Resources.getSystem().getDisplayMetrics().widthPixels, Resources.getSystem().getDisplayMetrics().heightPixels);
|
||||
// End Signal Custom Code Block
|
||||
|
||||
Rational targetAspectRatio;
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
|
||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
|
||||
// End Signal Custom Code Block
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
|
||||
// End Signal Custom Code Block
|
||||
|
||||
mImageCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation());
|
||||
mImageCapture = mImageCaptureBuilder.build();
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
Size size = VideoUtil.getVideoRecordingSize();
|
||||
mVideoCaptureBuilder.setTargetResolution(size);
|
||||
mVideoCaptureBuilder.setMaxResolution(size);
|
||||
// End Signal Custom Code Block
|
||||
|
||||
mVideoCaptureBuilder.setTargetRotation(getDisplaySurfaceRotation());
|
||||
// Begin Signal Custom Code Block
|
||||
if (MediaConstraints.isVideoTranscodeAvailable()) {
|
||||
mVideoCapture = mVideoCaptureBuilder.build();
|
||||
}
|
||||
// End Signal Custom Code Block
|
||||
|
||||
// Adjusts the preview resolution according to the view size and the target aspect ratio.
|
||||
int height = (int) (getMeasuredWidth() / targetAspectRatio.floatValue());
|
||||
mPreviewBuilder.setTargetResolution(new Size(getMeasuredWidth(), height));
|
||||
|
||||
mPreview = mPreviewBuilder.build();
|
||||
mPreview.setSurfaceProvider(mCameraView.getPreviewView().getSurfaceProvider());
|
||||
|
||||
CameraSelector cameraSelector =
|
||||
new CameraSelector.Builder().requireLensFacing(mCameraLensFacing).build();
|
||||
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
|
||||
mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
|
||||
mImageCapture,
|
||||
mPreview);
|
||||
} else if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
|
||||
mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
|
||||
mVideoCapture,
|
||||
mPreview);
|
||||
} else {
|
||||
mCamera = mCameraProvider.bindToLifecycle(mCurrentLifecycle, cameraSelector,
|
||||
mImageCapture,
|
||||
mVideoCapture, mPreview);
|
||||
}
|
||||
|
||||
setZoomRatio(UNITY_ZOOM_SCALE);
|
||||
mCurrentLifecycle.getLifecycle().addObserver(mCurrentLifecycleObserver);
|
||||
// Enable flash setting in ImageCapture after use cases are created and binded.
|
||||
setFlash(getFlash());
|
||||
}
|
||||
|
||||
public void open() {
|
||||
throw new UnsupportedOperationException(
|
||||
"Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
|
||||
}
|
||||
|
||||
public void close() {
|
||||
throw new UnsupportedOperationException(
|
||||
"Explicit open/close of camera not yet supported. Use bindtoLifecycle() instead.");
|
||||
}
|
||||
|
||||
public void takePicture(Executor executor, OnImageCapturedCallback callback) {
|
||||
if (mImageCapture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
|
||||
throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
|
||||
}
|
||||
|
||||
if (callback == null) {
|
||||
throw new IllegalArgumentException("OnImageCapturedCallback should not be empty");
|
||||
}
|
||||
|
||||
mImageCapture.takePicture(executor, callback);
|
||||
}
|
||||
|
||||
public void takePicture(@NonNull ImageCapture.OutputFileOptions outputFileOptions,
|
||||
@NonNull Executor executor, OnImageSavedCallback callback) {
|
||||
if (mImageCapture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getCaptureMode() == SignalCameraView.CaptureMode.VIDEO) {
|
||||
throw new IllegalStateException("Can not take picture under VIDEO capture mode.");
|
||||
}
|
||||
|
||||
if (callback == null) {
|
||||
throw new IllegalArgumentException("OnImageSavedCallback should not be empty");
|
||||
}
|
||||
|
||||
outputFileOptions.getMetadata().setReversedHorizontal(mCameraLensFacing != null
|
||||
&& mCameraLensFacing == CameraSelector.LENS_FACING_FRONT);
|
||||
mImageCapture.takePicture(outputFileOptions, executor, callback);
|
||||
}
|
||||
|
||||
public void startRecording(VideoCapture.OutputFileOptions outputFileOptions,
|
||||
Executor executor, final OnVideoSavedCallback callback) {
|
||||
if (mVideoCapture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
|
||||
throw new IllegalStateException("Can not record video under IMAGE capture mode.");
|
||||
}
|
||||
|
||||
if (callback == null) {
|
||||
throw new IllegalArgumentException("OnVideoSavedCallback should not be empty");
|
||||
}
|
||||
|
||||
mVideoIsRecording.set(true);
|
||||
mVideoCapture.startRecording(
|
||||
outputFileOptions,
|
||||
executor,
|
||||
new VideoCapture.OnVideoSavedCallback() {
|
||||
@Override
|
||||
public void onVideoSaved(
|
||||
@NonNull VideoCapture.OutputFileResults outputFileResults) {
|
||||
mVideoIsRecording.set(false);
|
||||
callback.onVideoSaved(outputFileResults);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(
|
||||
@VideoCapture.VideoCaptureError int videoCaptureError,
|
||||
@NonNull String message,
|
||||
@Nullable Throwable cause) {
|
||||
mVideoIsRecording.set(false);
|
||||
Logger.e(TAG, message, cause);
|
||||
callback.onError(videoCaptureError, message, cause);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void stopRecording() {
|
||||
if (mVideoCapture == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mVideoCapture.stopRecording();
|
||||
}
|
||||
|
||||
public boolean isRecording() {
|
||||
return mVideoIsRecording.get();
|
||||
}
|
||||
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
public void setCameraLensFacing(@Nullable Integer lensFacing) {
|
||||
// Setting same lens facing is a no-op, so check for that first
|
||||
if (!Objects.equals(mCameraLensFacing, lensFacing)) {
|
||||
// If we're not bound to a lifecycle, just update the camera that will be opened when we
|
||||
// attach to a lifecycle.
|
||||
mCameraLensFacing = lensFacing;
|
||||
|
||||
if (mCurrentLifecycle != null) {
|
||||
// Re-bind to lifecycle with new camera
|
||||
bindToLifecycle(mCurrentLifecycle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
public boolean hasCameraWithLensFacing(@CameraSelector.LensFacing int lensFacing) {
|
||||
if (mCameraProvider == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return mCameraProvider.hasCamera(
|
||||
new CameraSelector.Builder().requireLensFacing(lensFacing).build());
|
||||
} catch (CameraInfoUnavailableException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Integer getLensFacing() {
|
||||
return mCameraLensFacing;
|
||||
}
|
||||
|
||||
public void toggleCamera() {
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
Set<Integer> availableCameraLensFacing = getAvailableCameraLensFacing();
|
||||
|
||||
if (availableCameraLensFacing.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mCameraLensFacing == null) {
|
||||
setCameraLensFacing(availableCameraLensFacing.iterator().next());
|
||||
return;
|
||||
}
|
||||
|
||||
if (mCameraLensFacing == CameraSelector.LENS_FACING_BACK
|
||||
&& availableCameraLensFacing.contains(CameraSelector.LENS_FACING_FRONT)) {
|
||||
setCameraLensFacing(CameraSelector.LENS_FACING_FRONT);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mCameraLensFacing == CameraSelector.LENS_FACING_FRONT
|
||||
&& availableCameraLensFacing.contains(CameraSelector.LENS_FACING_BACK)) {
|
||||
setCameraLensFacing(CameraSelector.LENS_FACING_BACK);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public float getZoomRatio() {
|
||||
if (mCamera != null) {
|
||||
return mCamera.getCameraInfo().getZoomState().getValue().getZoomRatio();
|
||||
} else {
|
||||
return UNITY_ZOOM_SCALE;
|
||||
}
|
||||
}
|
||||
|
||||
public void setZoomRatio(float zoomRatio) {
|
||||
if (mCamera != null) {
|
||||
ListenableFuture<Void> future = mCamera.getCameraControl().setZoomRatio(
|
||||
zoomRatio);
|
||||
Futures.addCallback(future, new FutureCallback<Void>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Void result) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
// Throw the unexpected error.
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
}, CameraXExecutors.directExecutor());
|
||||
} else {
|
||||
Logger.e(TAG, "Failed to set zoom ratio");
|
||||
}
|
||||
}
|
||||
|
||||
public float getMinZoomRatio() {
|
||||
if (mCamera != null) {
|
||||
return mCamera.getCameraInfo().getZoomState().getValue().getMinZoomRatio();
|
||||
} else {
|
||||
return UNITY_ZOOM_SCALE;
|
||||
}
|
||||
}
|
||||
|
||||
public float getMaxZoomRatio() {
|
||||
if (mCamera != null) {
|
||||
return mCamera.getCameraInfo().getZoomState().getValue().getMaxZoomRatio();
|
||||
} else {
|
||||
return ZOOM_NOT_SUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isZoomSupported() {
|
||||
return getMaxZoomRatio() != ZOOM_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// TODO(b/124269166): Rethink how we can handle permissions here.
|
||||
@SuppressLint("MissingPermission")
|
||||
private void rebindToLifecycle() {
|
||||
if (mCurrentLifecycle != null) {
|
||||
bindToLifecycle(mCurrentLifecycle);
|
||||
}
|
||||
}
|
||||
|
||||
boolean isBoundToLifecycle() {
|
||||
return mCamera != null;
|
||||
}
|
||||
|
||||
int getRelativeCameraOrientation(boolean compensateForMirroring) {
|
||||
int rotationDegrees = 0;
|
||||
if (mCamera != null) {
|
||||
rotationDegrees =
|
||||
mCamera.getCameraInfo().getSensorRotationDegrees(getDisplaySurfaceRotation());
|
||||
if (compensateForMirroring) {
|
||||
rotationDegrees = (360 - rotationDegrees) % 360;
|
||||
}
|
||||
}
|
||||
|
||||
return rotationDegrees;
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeExperimentalUsageError")
|
||||
public void invalidateView() {
|
||||
if (mPreview != null) {
|
||||
mPreview.setTargetRotation(getDisplaySurfaceRotation()); // Fixes issue #10940 (rotation not updated on phones using "Legacy API")
|
||||
}
|
||||
|
||||
updateViewInfo();
|
||||
}
|
||||
|
||||
void clearCurrentLifecycle() {
|
||||
if (mCurrentLifecycle != null && mCameraProvider != null) {
|
||||
// Remove previous use cases
|
||||
List<UseCase> toUnbind = new ArrayList<>();
|
||||
if (mImageCapture != null && mCameraProvider.isBound(mImageCapture)) {
|
||||
toUnbind.add(mImageCapture);
|
||||
}
|
||||
if (mVideoCapture != null && mCameraProvider.isBound(mVideoCapture)) {
|
||||
toUnbind.add(mVideoCapture);
|
||||
}
|
||||
if (mPreview != null && mCameraProvider.isBound(mPreview)) {
|
||||
toUnbind.add(mPreview);
|
||||
}
|
||||
|
||||
if (!toUnbind.isEmpty()) {
|
||||
mCameraProvider.unbind(toUnbind.toArray((new UseCase[0])));
|
||||
}
|
||||
|
||||
// Remove surface provider once unbound.
|
||||
if (mPreview != null) {
|
||||
mPreview.setSurfaceProvider(null);
|
||||
}
|
||||
}
|
||||
mCamera = null;
|
||||
mCurrentLifecycle = null;
|
||||
}
|
||||
|
||||
// Update view related information used in use cases
|
||||
private void updateViewInfo() {
|
||||
if (mImageCapture != null) {
|
||||
mImageCapture.setCropAspectRatio(new Rational(getWidth(), getHeight()));
|
||||
mImageCapture.setTargetRotation(getDisplaySurfaceRotation());
|
||||
}
|
||||
|
||||
if (mVideoCapture != null) {
|
||||
mVideoCapture.setTargetRotation(getDisplaySurfaceRotation());
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresPermission(permission.CAMERA)
|
||||
private Set<Integer> getAvailableCameraLensFacing() {
|
||||
// Start with all camera directions
|
||||
Set<Integer> available = new LinkedHashSet<>(Arrays.asList(LensFacingConverter.values()));
|
||||
|
||||
// If we're bound to a lifecycle, remove unavailable cameras
|
||||
if (mCurrentLifecycle != null) {
|
||||
if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_BACK)) {
|
||||
available.remove(CameraSelector.LENS_FACING_BACK);
|
||||
}
|
||||
|
||||
if (!hasCameraWithLensFacing(CameraSelector.LENS_FACING_FRONT)) {
|
||||
available.remove(CameraSelector.LENS_FACING_FRONT);
|
||||
}
|
||||
}
|
||||
|
||||
return available;
|
||||
}
|
||||
|
||||
@ImageCapture.FlashMode
|
||||
public int getFlash() {
|
||||
return mFlash;
|
||||
}
|
||||
|
||||
// Begin Signal Custom Code Block
|
||||
public boolean hasFlash() {
|
||||
if (mImageCapture == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
CameraInternal camera = mImageCapture.getCamera();
|
||||
|
||||
if (camera == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return camera.getCameraInfoInternal().hasFlashUnit();
|
||||
}
|
||||
// End Signal Custom Code Block
|
||||
|
||||
public void setFlash(@ImageCapture.FlashMode int flash) {
|
||||
this.mFlash = flash;
|
||||
|
||||
if (mImageCapture == null) {
|
||||
// Do nothing if there is no imageCapture
|
||||
return;
|
||||
}
|
||||
|
||||
mImageCapture.setFlashMode(flash);
|
||||
}
|
||||
|
||||
public void enableTorch(boolean torch) {
|
||||
if (mCamera == null) {
|
||||
return;
|
||||
}
|
||||
ListenableFuture<Void> future = mCamera.getCameraControl().enableTorch(torch);
|
||||
Futures.addCallback(future, new FutureCallback<Void>() {
|
||||
@Override
|
||||
public void onSuccess(@Nullable Void result) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Throwable t) {
|
||||
// Throw the unexpected error.
|
||||
throw new RuntimeException(t);
|
||||
}
|
||||
}, CameraXExecutors.directExecutor());
|
||||
}
|
||||
|
||||
public boolean isTorchOn() {
|
||||
if (mCamera == null) {
|
||||
return false;
|
||||
}
|
||||
return mCamera.getCameraInfo().getTorchState().getValue() == TorchState.ON;
|
||||
}
|
||||
|
||||
public Context getContext() {
|
||||
return mCameraView.getContext();
|
||||
}
|
||||
|
||||
public int getWidth() {
|
||||
return mCameraView.getWidth();
|
||||
}
|
||||
|
||||
public int getHeight() {
|
||||
return mCameraView.getHeight();
|
||||
}
|
||||
|
||||
public int getDisplayRotationDegrees() {
|
||||
return CameraOrientationUtil.surfaceRotationToDegrees(getDisplaySurfaceRotation());
|
||||
}
|
||||
|
||||
protected int getDisplaySurfaceRotation() {
|
||||
return mCameraView.getDisplaySurfaceRotation();
|
||||
}
|
||||
|
||||
private int getMeasuredWidth() {
|
||||
return mCameraView.getMeasuredWidth();
|
||||
}
|
||||
|
||||
private int getMeasuredHeight() {
|
||||
return mCameraView.getMeasuredHeight();
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public Camera getCamera() {
|
||||
return mCamera;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public SignalCameraView.CaptureMode getCaptureMode() {
|
||||
return mCaptureMode;
|
||||
}
|
||||
|
||||
public void setCaptureMode(@NonNull SignalCameraView.CaptureMode captureMode) {
|
||||
this.mCaptureMode = captureMode;
|
||||
rebindToLifecycle();
|
||||
}
|
||||
|
||||
public long getMaxVideoDuration() {
|
||||
return mMaxVideoDuration;
|
||||
}
|
||||
|
||||
public void setMaxVideoDuration(long duration) {
|
||||
mMaxVideoDuration = duration;
|
||||
}
|
||||
|
||||
public long getMaxVideoSize() {
|
||||
return mMaxVideoSize;
|
||||
}
|
||||
|
||||
public void setMaxVideoSize(long size) {
|
||||
mMaxVideoSize = size;
|
||||
}
|
||||
|
||||
public boolean isPaused() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,25 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||
|
||||
public final class AppCapabilities {
|
||||
|
||||
private AppCapabilities() {
|
||||
}
|
||||
|
||||
private static final boolean UUID_CAPABLE = false;
|
||||
private static final boolean GV2_CAPABLE = true;
|
||||
private static final boolean GV1_MIGRATION = true;
|
||||
private static final boolean ANNOUNCEMENT_GROUPS = true;
|
||||
private static final boolean SENDER_KEY = true;
|
||||
private static final boolean CHANGE_NUMBER = 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.giftBadges());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.thoughtcrime.securesms
|
||||
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.whispersystems.signalservice.api.account.AccountAttributes
|
||||
|
||||
object AppCapabilities {
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getCapabilities(storageCapable: Boolean): AccountAttributes.Capabilities {
|
||||
return AccountAttributes.Capabilities(
|
||||
isUuid = false,
|
||||
isGv2 = true,
|
||||
isStorage = storageCapable,
|
||||
isGv1Migration = true,
|
||||
isSenderKey = true,
|
||||
isAnnouncementGroup = true,
|
||||
isChangeNumber = true,
|
||||
isStories = true,
|
||||
isGiftBadges = true,
|
||||
isPnp = FeatureFlags.phoneNumberPrivacy(),
|
||||
paymentActivation = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,13 @@
|
||||
*/
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.StrictMode;
|
||||
|
||||
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;
|
||||
@@ -35,6 +36,8 @@ import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.AndroidLogger;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.core.util.tracing.Tracer;
|
||||
import org.signal.donations.GooglePayApi;
|
||||
import org.signal.donations.StripeApi;
|
||||
import org.signal.glide.SignalGlideCodecs;
|
||||
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||
import org.signal.ringrtc.CallManager;
|
||||
@@ -50,19 +53,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;
|
||||
@@ -99,9 +104,10 @@ import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWra
|
||||
import java.net.SocketException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.security.Security;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.reactivex.rxjava3.core.CompletableObserver;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||
@@ -155,7 +161,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("rx-init", this::initializeRx)
|
||||
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
|
||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||
.addBlocking("app-migrations", this::initializeApplicationMigrations)
|
||||
.addBlocking("ring-rtc", this::initializeRingRtc)
|
||||
@@ -177,17 +182,17 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||
.addBlocking("feature-flags", FeatureFlags::init)
|
||||
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
|
||||
.addNonBlocking(this::checkIsGooglePayReady)
|
||||
.addNonBlocking(this::cleanAvatarStorage)
|
||||
.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)
|
||||
@@ -197,6 +202,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()))
|
||||
@@ -206,6 +212,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
|
||||
.addPostRender(PnpInitializeDevicesJob::enqueueIfNecessary)
|
||||
.execute();
|
||||
|
||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||
@@ -221,7 +229,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getFrameRateTracker().start();
|
||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||
ApplicationDependencies.getDeadlockDetector().start();
|
||||
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary();
|
||||
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
FeatureFlags.refreshIfNecessary();
|
||||
@@ -231,6 +239,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");
|
||||
@@ -330,7 +348,8 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
ApplicationDependencies.getIncomingMessageObserver();
|
||||
}
|
||||
|
||||
private void initializeAppDependencies() {
|
||||
@VisibleForTesting
|
||||
void initializeAppDependencies() {
|
||||
ApplicationDependencies.init(this, new ApplicationDependencyProvider(this));
|
||||
}
|
||||
|
||||
@@ -374,6 +393,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);
|
||||
@@ -440,6 +466,18 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
||||
AvatarPickerStorage.cleanOrphans(this);
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
private void checkIsGooglePayReady() {
|
||||
GooglePayApi.queryIsReadyToPay(
|
||||
this,
|
||||
new StripeApi.Gateway(Environment.Donations.getStripeConfiguration()),
|
||||
Environment.Donations.getGooglePayConfiguration()
|
||||
).subscribe(
|
||||
/* onComplete = */ () -> SignalStore.donationsValues().setGooglePayReady(true),
|
||||
/* onError = */ t -> SignalStore.donationsValues().setGooglePayReady(false)
|
||||
);
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private void initializeCleanup() {
|
||||
int deleted = SignalDatabase.attachments().deleteAbandonedPreuploadedAttachments();
|
||||
|
||||
@@ -92,7 +92,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
||||
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
|
||||
|
||||
Recipient.live(recipientId).observe(this, recipient -> {
|
||||
ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient, recipient.getProfileAvatar())
|
||||
ContactPhoto contactPhoto = recipient.isSelf() ? new ProfileContactPhoto(recipient)
|
||||
: recipient.getContactPhoto();
|
||||
FallbackContactPhoto fallbackPhoto = recipient.isSelf() ? new ResourceContactPhoto(R.drawable.ic_profile_outline_40, R.drawable.ic_profile_outline_20, R.drawable.ic_person_large)
|
||||
: recipient.getFallbackContactPhoto();
|
||||
|
||||
@@ -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;
|
||||
@@ -46,7 +47,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
boolean hasWallpaper,
|
||||
boolean isMessageRequestAccepted,
|
||||
boolean canPlayInline,
|
||||
@NonNull Colorizer colorizer);
|
||||
@NonNull Colorizer colorizer,
|
||||
@NonNull ConversationItemDisplayMode displayMode);
|
||||
|
||||
@NonNull ConversationMessage getConversationMessage();
|
||||
|
||||
@@ -61,12 +63,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
}
|
||||
|
||||
default void updateSelectedState() {
|
||||
// Intentionall Blank.
|
||||
// Intentionally Blank.
|
||||
}
|
||||
|
||||
interface EventListener {
|
||||
void onQuoteClicked(MmsMessageRecord messageRecord);
|
||||
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
||||
void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord);
|
||||
void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms);
|
||||
void onStickerClicked(@NonNull StickerLocator stickerLocator);
|
||||
void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
|
||||
@@ -100,10 +103,14 @@ 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);
|
||||
|
||||
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
@@ -11,7 +12,8 @@ import java.util.Set;
|
||||
|
||||
public interface BindableConversationListItem extends Unbindable {
|
||||
|
||||
void bind(@NonNull ThreadRecord thread,
|
||||
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
||||
@NonNull ThreadRecord thread,
|
||||
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
||||
@NonNull Set<Long> typingThreads,
|
||||
@NonNull ConversationSet selectedConversations);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms;
|
||||
|
||||
|
||||
import android.Manifest;
|
||||
import android.animation.LayoutTransition;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -32,7 +31,6 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.WindowManager;
|
||||
import android.widget.Button;
|
||||
import android.widget.HorizontalScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
@@ -43,6 +41,7 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||
import androidx.constraintlayout.widget.ConstraintSet;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||
@@ -54,15 +53,14 @@ import androidx.transition.TransitionManager;
|
||||
|
||||
import com.annimon.stream.Collectors;
|
||||
import com.annimon.stream.Stream;
|
||||
import com.google.android.material.chip.ChipGroup;
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
|
||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChip;
|
||||
import org.thoughtcrime.securesms.contacts.ContactChipViewModel;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListAdapter;
|
||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||
@@ -70,22 +68,25 @@ import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||
import org.thoughtcrime.securesms.contacts.SelectedContacts;
|
||||
import org.thoughtcrime.securesms.contacts.selection.ContactSelectionArguments;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sharing.ShareContact;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter;
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModelList;
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -95,6 +96,9 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
/**
|
||||
* Fragment for selecting a one or more contacts from a list.
|
||||
*
|
||||
@@ -106,7 +110,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
@SuppressWarnings("unused")
|
||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
||||
|
||||
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 1;
|
||||
private static final int CHIP_GROUP_EMPTY_CHILD_COUNT = 0;
|
||||
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
|
||||
|
||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||
@@ -134,25 +138,26 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private RecyclerView recyclerView;
|
||||
private RecyclerViewFastScroller fastScroller;
|
||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||
private ChipGroup chipGroup;
|
||||
private HorizontalScrollView chipGroupScrollContainer;
|
||||
private RecyclerView chipRecycler;
|
||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||
private View shadowView;
|
||||
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
||||
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) {
|
||||
@@ -201,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
|
||||
@@ -250,17 +263,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||
showContactsProgress = view.findViewById(R.id.progress);
|
||||
chipGroup = view.findViewById(R.id.chipGroup);
|
||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||
constraintLayout = view.findViewById(R.id.container);
|
||||
shadowView = view.findViewById(R.id.toolbar_shadow);
|
||||
headerActionView = view.findViewById(R.id.header_action);
|
||||
|
||||
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
|
||||
|
||||
final LinearLayoutManager layoutManager = new LinearLayoutManager(requireContext());
|
||||
|
||||
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
|
||||
recyclerView.setLayoutManager(layoutManager);
|
||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||
@Override
|
||||
@@ -269,6 +277,18 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
});
|
||||
|
||||
contactChipViewModel = new ViewModelProvider(this).get(ContactChipViewModel.class);
|
||||
contactChipAdapter = new MappingAdapter();
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
SelectedContacts.register(contactChipAdapter, this::onChipCloseIconClicked);
|
||||
chipRecycler.setAdapter(contactChipAdapter);
|
||||
|
||||
Disposable disposable = contactChipViewModel.getState().subscribe(this::handleSelectedContactsChanged);
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
|
||||
Intent intent = requireActivity().getIntent();
|
||||
Bundle arguments = safeArguments();
|
||||
|
||||
@@ -336,6 +356,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
constraintLayout = null;
|
||||
}
|
||||
|
||||
private @NonNull Bundle safeArguments() {
|
||||
return getArguments() != null ? getArguments() : new Bundle();
|
||||
}
|
||||
@@ -391,7 +417,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
null,
|
||||
new ListClickListener(),
|
||||
isMulti,
|
||||
currentSelection);
|
||||
currentSelection,
|
||||
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
|
||||
|
||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||
|
||||
@@ -640,7 +667,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
|
||||
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
|
||||
|
||||
if (!canSelectSelf && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
||||
if (!canSelectSelf && !selectedContact.hasUsername() && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
||||
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
@@ -707,6 +734,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() {
|
||||
@@ -734,75 +770,22 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||
removeChipForContact(selectedContact);
|
||||
contactChipViewModel.remove(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onSelectionChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
|
||||
View v = chipGroup.getChildAt(i);
|
||||
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
|
||||
chipGroup.removeView(v);
|
||||
}
|
||||
}
|
||||
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
|
||||
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
if (selectedContacts.isEmpty()) {
|
||||
setChipGroupVisibility(ConstraintSet.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> addChipForRecipient(resolved, selectedContact));
|
||||
}
|
||||
|
||||
private void addChipForRecipient(@NonNull Recipient recipient, @NonNull SelectedContact selectedContact) {
|
||||
final ContactChip chip = new ContactChip(requireContext());
|
||||
|
||||
if (getChipCount() == 0) {
|
||||
} else {
|
||||
setChipGroupVisibility(ConstraintSet.VISIBLE);
|
||||
}
|
||||
|
||||
chip.setText(recipient.getShortDisplayName(requireContext()));
|
||||
chip.setContact(selectedContact);
|
||||
chip.setCloseIconVisible(true);
|
||||
chip.setOnCloseIconClickListener(view -> {
|
||||
markContactUnselected(selectedContact);
|
||||
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(recipient.getId()), recipient.getE164().orElse(null));
|
||||
}
|
||||
});
|
||||
|
||||
chipGroup.getLayoutTransition().addTransitionListener(new LayoutTransition.TransitionListener() {
|
||||
@Override
|
||||
public void startTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
||||
if (getView() == null || !requireView().isAttachedToWindow()) {
|
||||
Log.w(TAG, "Fragment's view was detached before the animation completed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (view == chip && transitionType == LayoutTransition.APPEARING) {
|
||||
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
||||
registerChipRecipientObserver(chip, recipient.live());
|
||||
chipGroup.post(ContactSelectionListFragment.this::smoothScrollChipsToEnd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
chip.setAvatar(glideRequests, recipient, () -> addChip(chip));
|
||||
}
|
||||
|
||||
private void addChip(@NonNull ContactChip chip) {
|
||||
chipGroup.addView(chip);
|
||||
if (selectionWarningLimitReachedExactly()) {
|
||||
if (onSelectionLimitReachedListener != null) {
|
||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||
@@ -812,21 +795,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
}
|
||||
|
||||
private int getChipCount() {
|
||||
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
||||
if (count < 0) throw new AssertionError();
|
||||
return count;
|
||||
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||
resolved -> contactChipViewModel.add(selectedContact));
|
||||
}
|
||||
|
||||
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
|
||||
if (recipient != null) {
|
||||
recipient.observe(getViewLifecycleOwner(), resolved -> {
|
||||
if (chip.isAttachedToWindow()) {
|
||||
chip.setAvatar(glideRequests, resolved, null);
|
||||
chip.setText(resolved.getShortDisplayName(chip.getContext()));
|
||||
}
|
||||
});
|
||||
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
|
||||
markContactUnselected(model.getSelectedContact());
|
||||
if (onContactSelectedListener != null) {
|
||||
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
|
||||
}
|
||||
|
||||
return Unit.INSTANCE;
|
||||
}
|
||||
|
||||
private int getChipCount() {
|
||||
int count = contactChipViewModel.getCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
||||
if (count < 0) throw new AssertionError();
|
||||
return count;
|
||||
}
|
||||
|
||||
private void setChipGroupVisibility(int visibility) {
|
||||
@@ -842,7 +829,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
|
||||
ConstraintSet constraintSet = new ConstraintSet();
|
||||
constraintSet.clone(constraintLayout);
|
||||
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
|
||||
constraintSet.setVisibility(R.id.chipRecycler, visibility);
|
||||
constraintSet.applyTo(constraintLayout);
|
||||
}
|
||||
|
||||
@@ -851,8 +838,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
||||
}
|
||||
|
||||
private void smoothScrollChipsToEnd() {
|
||||
int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0;
|
||||
chipGroupScrollContainer.smoothScrollTo(x, 0);
|
||||
int x = ViewUtil.isLtr(chipRecycler) ? chipRecycler.getWidth() : 0;
|
||||
chipRecycler.smoothScrollBy(x, 0);
|
||||
}
|
||||
|
||||
public interface OnContactSelectedListener {
|
||||
@@ -886,6 +873,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();
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.ServiceConnection;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.IBinder;
|
||||
import android.os.Looper;
|
||||
import android.os.Message;
|
||||
import android.os.Parcelable;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.thoughtcrime.securesms.database.SmsMigrator.ProgressDescription;
|
||||
import org.thoughtcrime.securesms.service.ApplicationMigrationService;
|
||||
import org.thoughtcrime.securesms.service.ApplicationMigrationService.ImportState;
|
||||
|
||||
public class DatabaseMigrationActivity extends PassphraseRequiredActivity {
|
||||
|
||||
private final ImportServiceConnection serviceConnection = new ImportServiceConnection();
|
||||
private final ImportStateHandler importStateHandler = new ImportStateHandler();
|
||||
private final BroadcastReceiver completedReceiver = new NullReceiver();
|
||||
|
||||
private LinearLayout promptLayout;
|
||||
private LinearLayout progressLayout;
|
||||
private Button skipButton;
|
||||
private Button importButton;
|
||||
private ProgressBar progress;
|
||||
private TextView progressLabel;
|
||||
|
||||
private ApplicationMigrationService importService;
|
||||
private boolean isVisible = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle bundle, boolean ready) {
|
||||
setContentView(R.layout.database_migration_activity);
|
||||
|
||||
initializeResources();
|
||||
initializeServiceBinding();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
isVisible = true;
|
||||
registerForCompletedNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
isVisible = false;
|
||||
unregisterForCompletedNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
shutdownServiceBinding();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
|
||||
}
|
||||
|
||||
private void initializeServiceBinding() {
|
||||
Intent intent = new Intent(this, ApplicationMigrationService.class);
|
||||
bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE);
|
||||
}
|
||||
|
||||
private void initializeResources() {
|
||||
this.promptLayout = (LinearLayout)findViewById(R.id.prompt_layout);
|
||||
this.progressLayout = (LinearLayout)findViewById(R.id.progress_layout);
|
||||
this.skipButton = (Button) findViewById(R.id.skip_button);
|
||||
this.importButton = (Button) findViewById(R.id.import_button);
|
||||
this.progress = (ProgressBar) findViewById(R.id.import_progress);
|
||||
this.progressLabel = (TextView) findViewById(R.id.import_status);
|
||||
|
||||
this.progressLayout.setVisibility(View.GONE);
|
||||
this.promptLayout.setVisibility(View.GONE);
|
||||
|
||||
this.importButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
Intent intent = new Intent(DatabaseMigrationActivity.this, ApplicationMigrationService.class);
|
||||
intent.setAction(ApplicationMigrationService.MIGRATE_DATABASE);
|
||||
intent.putExtra("master_secret", (Parcelable)getIntent().getParcelableExtra("master_secret"));
|
||||
startService(intent);
|
||||
|
||||
promptLayout.setVisibility(View.GONE);
|
||||
progressLayout.setVisibility(View.VISIBLE);
|
||||
}
|
||||
});
|
||||
|
||||
this.skipButton.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
ApplicationMigrationService.setDatabaseImported(DatabaseMigrationActivity.this);
|
||||
handleImportComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void registerForCompletedNotification() {
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(ApplicationMigrationService.COMPLETED_ACTION);
|
||||
filter.setPriority(1000);
|
||||
|
||||
registerReceiver(completedReceiver, filter);
|
||||
}
|
||||
|
||||
private void unregisterForCompletedNotification() {
|
||||
unregisterReceiver(completedReceiver);
|
||||
}
|
||||
|
||||
private void shutdownServiceBinding() {
|
||||
unbindService(serviceConnection);
|
||||
}
|
||||
|
||||
private void handleStateIdle() {
|
||||
this.promptLayout.setVisibility(View.VISIBLE);
|
||||
this.progressLayout.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
private void handleStateProgress(ProgressDescription update) {
|
||||
this.promptLayout.setVisibility(View.GONE);
|
||||
this.progressLayout.setVisibility(View.VISIBLE);
|
||||
this.progressLabel.setText(update.primaryComplete + "/" + update.primaryTotal);
|
||||
|
||||
double max = this.progress.getMax();
|
||||
double primaryTotal = update.primaryTotal;
|
||||
double primaryComplete = update.primaryComplete;
|
||||
double secondaryTotal = update.secondaryTotal;
|
||||
double secondaryComplete = update.secondaryComplete;
|
||||
|
||||
this.progress.setProgress((int)Math.round((primaryComplete / primaryTotal) * max));
|
||||
this.progress.setSecondaryProgress((int)Math.round((secondaryComplete / secondaryTotal) * max));
|
||||
}
|
||||
|
||||
private void handleImportComplete() {
|
||||
if (isVisible) {
|
||||
if (getIntent().hasExtra("next_intent")) {
|
||||
startActivity((Intent)getIntent().getParcelableExtra("next_intent"));
|
||||
} else {
|
||||
// TODO [greyson] Navigation
|
||||
startActivity(MainActivity.clearTop(this));
|
||||
}
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
private class ImportStateHandler extends Handler {
|
||||
|
||||
public ImportStateHandler() {
|
||||
super(Looper.getMainLooper());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleMessage(Message message) {
|
||||
switch (message.what) {
|
||||
case ImportState.STATE_IDLE: handleStateIdle(); break;
|
||||
case ImportState.STATE_MIGRATING_IN_PROGRESS: handleStateProgress((ProgressDescription)message.obj); break;
|
||||
case ImportState.STATE_MIGRATING_COMPLETE: handleImportComplete(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ImportServiceConnection implements ServiceConnection {
|
||||
@Override
|
||||
public void onServiceConnected(ComponentName className, IBinder service) {
|
||||
importService = ((ApplicationMigrationService.ApplicationMigrationBinder)service).getService();
|
||||
importService.setImportStateHandler(importStateHandler);
|
||||
|
||||
ImportState state = importService.getState();
|
||||
importStateHandler.obtainMessage(state.state, state.progress).sendToTarget();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(ComponentName name) {
|
||||
importService.setImportStateHandler(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static class NullReceiver extends BroadcastReceiver {
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
abortBroadcast();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -25,11 +27,11 @@ import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.signal.libsignal.protocol.ecc.Curve;
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.qr.kitkat.ScanListener;
|
||||
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||
@@ -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,48 +2,47 @@ 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.thoughtcrime.securesms.components.camera.CameraView;
|
||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
||||
import org.thoughtcrime.securesms.qr.ScanningThread;
|
||||
import org.signal.qr.QrScannerView;
|
||||
import org.signal.qr.kitkat.ScanListener;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
public class DeviceAddFragment extends LoggingFragment {
|
||||
|
||||
private ViewGroup container;
|
||||
private LinearLayout overlay;
|
||||
private ImageView devicesImage;
|
||||
private CameraView scannerView;
|
||||
private ScanningThread scanningThread;
|
||||
private ScanListener scanListener;
|
||||
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||
|
||||
private ImageView devicesImage;
|
||||
private ScanListener scanListener;
|
||||
private QrScannerView scannerView;
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
|
||||
this.overlay = this.container.findViewById(R.id.overlay);
|
||||
this.scannerView = this.container.findViewById(R.id.scanner);
|
||||
this.devicesImage = this.container.findViewById(R.id.devices);
|
||||
ViewGroup container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
|
||||
|
||||
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
this.overlay.setOrientation(LinearLayout.HORIZONTAL);
|
||||
} else {
|
||||
this.overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
}
|
||||
this.scannerView = container.findViewById(R.id.scanner);
|
||||
this.devicesImage = container.findViewById(R.id.devices);
|
||||
ViewCompat.setTransitionName(devicesImage, "devices");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 21) {
|
||||
this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
||||
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
||||
@TargetApi(21)
|
||||
@Override
|
||||
public void onLayoutChange(View v, int left, int top, int right, int bottom,
|
||||
@@ -59,52 +58,43 @@ public class DeviceAddFragment extends LoggingFragment {
|
||||
});
|
||||
}
|
||||
|
||||
return this.container;
|
||||
scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted());
|
||||
|
||||
lifecycleDisposable.bindTo(getViewLifecycleOwner());
|
||||
|
||||
Disposable qrDisposable = scannerView
|
||||
.getQrData()
|
||||
.distinctUntilChanged()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(qrData -> {
|
||||
if (scanListener != null) {
|
||||
scanListener.onQrDataFound(qrData);
|
||||
}
|
||||
});
|
||||
|
||||
lifecycleDisposable.add(qrDisposable);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
this.scanningThread = new ScanningThread();
|
||||
this.scanningThread.setScanListener(scanListener);
|
||||
this.scannerView.onResume();
|
||||
this.scannerView.setPreviewCallback(scanningThread);
|
||||
this.scanningThread.start();
|
||||
}
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
MenuItem switchCamera = ((DeviceActivity) requireActivity()).getCameraSwitchItem();
|
||||
|
||||
@Override
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
this.scannerView.onPause();
|
||||
this.scanningThread.stopScanning();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfiguration) {
|
||||
super.onConfigurationChanged(newConfiguration);
|
||||
|
||||
this.scannerView.onPause();
|
||||
|
||||
if (newConfiguration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
overlay.setOrientation(LinearLayout.HORIZONTAL);
|
||||
} else {
|
||||
overlay.setOrientation(LinearLayout.VERTICAL);
|
||||
if (switchCamera != null) {
|
||||
switchCamera.setVisible(true);
|
||||
switchCamera.setOnMenuItemClickListener(v -> {
|
||||
scannerView.toggleCamera();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
this.scannerView.onResume();
|
||||
this.scannerView.setPreviewCallback(scanningThread);
|
||||
}
|
||||
|
||||
|
||||
public ImageView getDevicesImage() {
|
||||
return devicesImage;
|
||||
}
|
||||
|
||||
public void setScanListener(ScanListener scanListener) {
|
||||
this.scanListener = scanListener;
|
||||
|
||||
if (this.scanningThread != null) {
|
||||
this.scanningThread.setScanListener(scanListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
||||
public class DeviceLinkFragment extends Fragment implements View.OnClickListener {
|
||||
@@ -21,6 +22,7 @@ public class DeviceLinkFragment extends Fragment implements View.OnClickListener
|
||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||
this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
|
||||
this.container.findViewById(R.id.link_device).setOnClickListener(this);
|
||||
ViewCompat.setTransitionName(container.findViewById(R.id.devices), "devices");
|
||||
|
||||
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
container.setOrientation(LinearLayout.HORIZONTAL);
|
||||
|
||||
@@ -21,7 +21,7 @@ import androidx.loader.app.LoaderManager;
|
||||
import androidx.loader.content.Loader;
|
||||
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||
import com.melnykov.fab.FloatingActionButton;
|
||||
import com.google.android.material.floatingactionbutton.FloatingActionButton;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
||||
|
||||
@@ -27,10 +27,11 @@ 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.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
@@ -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 {
|
||||
@@ -253,7 +254,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
||||
Recipient recipient = Recipient.resolved(recipientId);
|
||||
int subscriptionId = recipient.getDefaultSubscriptionId().orElse(-1);
|
||||
|
||||
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
|
||||
MessageSender.send(context, OutgoingMessage.sms(recipient, message, subscriptionId), -1L, true, null, null);
|
||||
|
||||
if (recipient.getContactUri() != null) {
|
||||
SignalDatabase.recipients().setHasSentInvite(recipient.getId());
|
||||
|
||||
@@ -125,10 +125,10 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
||||
private void updateTabVisibility() {
|
||||
if (Stories.isFeatureEnabled()) {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_colorSecondaryContainer));
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||
} else {
|
||||
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||
WindowUtil.setNavigationBarColor(getWindow(), ContextCompat.getColor(this, R.color.signal_background_primary));
|
||||
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
|
||||
conversationListTabsViewModel.onChatsSelected();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,890 +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 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,
|
||||
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 AlertDialog.Builder(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);
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.os.Parcel;
|
||||
import android.os.Parcelable;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
|
||||
public class TransportOption implements Parcelable {
|
||||
|
||||
public enum Type {
|
||||
SMS,
|
||||
TEXTSECURE
|
||||
}
|
||||
|
||||
private final int drawable;
|
||||
private final int backgroundColor;
|
||||
private final @NonNull String text;
|
||||
private final @NonNull Type type;
|
||||
private final @NonNull String composeHint;
|
||||
private final @NonNull CharacterCalculator characterCalculator;
|
||||
private final @NonNull Optional<CharSequence> simName;
|
||||
private final @NonNull Optional<Integer> simSubscriptionId;
|
||||
|
||||
public TransportOption(@NonNull Type type,
|
||||
@DrawableRes int drawable,
|
||||
int backgroundColor,
|
||||
@NonNull String text,
|
||||
@NonNull String composeHint,
|
||||
@NonNull CharacterCalculator characterCalculator)
|
||||
{
|
||||
this(type, drawable, backgroundColor, text, composeHint, characterCalculator,
|
||||
Optional.empty(), Optional.empty());
|
||||
}
|
||||
|
||||
public TransportOption(@NonNull Type type,
|
||||
@DrawableRes int drawable,
|
||||
int backgroundColor,
|
||||
@NonNull String text,
|
||||
@NonNull String composeHint,
|
||||
@NonNull CharacterCalculator characterCalculator,
|
||||
@NonNull Optional<CharSequence> simName,
|
||||
@NonNull Optional<Integer> simSubscriptionId)
|
||||
{
|
||||
this.type = type;
|
||||
this.drawable = drawable;
|
||||
this.backgroundColor = backgroundColor;
|
||||
this.text = text;
|
||||
this.composeHint = composeHint;
|
||||
this.characterCalculator = characterCalculator;
|
||||
this.simName = simName;
|
||||
this.simSubscriptionId = simSubscriptionId;
|
||||
}
|
||||
|
||||
TransportOption(Parcel in) {
|
||||
this(Type.valueOf(in.readString()),
|
||||
in.readInt(),
|
||||
in.readInt(),
|
||||
in.readString(),
|
||||
in.readString(),
|
||||
CharacterCalculator.readFromParcel(in),
|
||||
Optional.ofNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
|
||||
in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.empty());
|
||||
}
|
||||
|
||||
public @NonNull Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isType(Type type) {
|
||||
return this.type == type;
|
||||
}
|
||||
|
||||
public boolean isSms() {
|
||||
return type == Type.SMS;
|
||||
}
|
||||
|
||||
public CharacterState calculateCharacters(String messageBody) {
|
||||
return characterCalculator.calculateCharacters(messageBody);
|
||||
}
|
||||
|
||||
public @DrawableRes int getDrawable() {
|
||||
return drawable;
|
||||
}
|
||||
|
||||
public int getBackgroundColor() {
|
||||
return backgroundColor;
|
||||
}
|
||||
|
||||
public @NonNull String getComposeHint() {
|
||||
return composeHint;
|
||||
}
|
||||
|
||||
public @NonNull String getDescription() {
|
||||
return text;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Optional<CharSequence> getSimName() {
|
||||
return simName;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public Optional<Integer> getSimSubscriptionId() {
|
||||
return simSubscriptionId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int describeContents() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void writeToParcel(Parcel dest, int flags) {
|
||||
dest.writeString(type.name());
|
||||
dest.writeInt(drawable);
|
||||
dest.writeInt(backgroundColor);
|
||||
dest.writeString(text);
|
||||
dest.writeString(composeHint);
|
||||
CharacterCalculator.writeToParcel(dest, characterCalculator);
|
||||
TextUtils.writeToParcel(simName.orElse(null), dest, flags);
|
||||
|
||||
if (simSubscriptionId.isPresent()) {
|
||||
dest.writeInt(1);
|
||||
dest.writeInt(simSubscriptionId.get());
|
||||
} else {
|
||||
dest.writeInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
public static final Creator<TransportOption> CREATOR = new Creator<TransportOption>() {
|
||||
@Override
|
||||
public TransportOption createFromParcel(Parcel in) {
|
||||
return new TransportOption(in);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportOption[] newArray(int size) {
|
||||
return new TransportOption[size];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.util.CharacterCalculator;
|
||||
import org.thoughtcrime.securesms.util.MmsCharacterCalculator;
|
||||
import org.thoughtcrime.securesms.util.PushCharacterCalculator;
|
||||
import org.thoughtcrime.securesms.util.SmsCharacterCalculator;
|
||||
import org.thoughtcrime.securesms.util.dualsim.SubscriptionInfoCompat;
|
||||
import org.thoughtcrime.securesms.util.dualsim.SubscriptionManagerCompat;
|
||||
import org.whispersystems.signalservice.api.util.OptionalUtil;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.thoughtcrime.securesms.TransportOption.Type;
|
||||
|
||||
public class TransportOptions {
|
||||
|
||||
private static final String TAG = Log.tag(TransportOptions.class);
|
||||
|
||||
private final List<OnTransportChangedListener> listeners = new LinkedList<>();
|
||||
private final Context context;
|
||||
private final List<TransportOption> enabledTransports;
|
||||
|
||||
private Type defaultTransportType = Type.SMS;
|
||||
private Optional<Integer> defaultSubscriptionId = Optional.empty();
|
||||
private Optional<TransportOption> selectedOption = Optional.empty();
|
||||
|
||||
private final Optional<Integer> systemSubscriptionId;
|
||||
|
||||
public TransportOptions(Context context, boolean media) {
|
||||
this.context = context;
|
||||
this.enabledTransports = initializeAvailableTransports(media);
|
||||
this.systemSubscriptionId = new SubscriptionManagerCompat(context).getPreferredSubscriptionId();
|
||||
}
|
||||
|
||||
public void reset(boolean media) {
|
||||
List<TransportOption> transportOptions = initializeAvailableTransports(media);
|
||||
|
||||
this.enabledTransports.clear();
|
||||
this.enabledTransports.addAll(transportOptions);
|
||||
|
||||
if (selectedOption.isPresent() && !isEnabled(selectedOption.get())) {
|
||||
setSelectedTransport(null);
|
||||
} else {
|
||||
this.defaultTransportType = Type.SMS;
|
||||
this.defaultSubscriptionId = Optional.empty();
|
||||
|
||||
notifyTransportChangeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public void setDefaultTransport(Type type) {
|
||||
this.defaultTransportType = type;
|
||||
|
||||
if (!selectedOption.isPresent()) {
|
||||
notifyTransportChangeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public void setDefaultSubscriptionId(Optional<Integer> subscriptionId) {
|
||||
if (defaultSubscriptionId.equals(subscriptionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.defaultSubscriptionId = subscriptionId;
|
||||
|
||||
if (!selectedOption.isPresent()) {
|
||||
notifyTransportChangeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public void setSelectedTransport(@Nullable TransportOption transportOption) {
|
||||
this.selectedOption = Optional.ofNullable(transportOption);
|
||||
notifyTransportChangeListeners();
|
||||
}
|
||||
|
||||
public boolean isManualSelection() {
|
||||
return this.selectedOption.isPresent();
|
||||
}
|
||||
|
||||
public @NonNull TransportOption getSelectedTransport() {
|
||||
if (selectedOption.isPresent()) return selectedOption.get();
|
||||
|
||||
if (defaultTransportType == Type.SMS) {
|
||||
TransportOption transportOption = findEnabledSmsTransportOption(OptionalUtil.or(defaultSubscriptionId, systemSubscriptionId));
|
||||
if (transportOption != null) {
|
||||
return transportOption;
|
||||
}
|
||||
}
|
||||
|
||||
for (TransportOption transportOption : enabledTransports) {
|
||||
if (transportOption.getType() == defaultTransportType) {
|
||||
return transportOption;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AssertionError("No options of default type!");
|
||||
}
|
||||
|
||||
public static @NonNull TransportOption getPushTransportOption(@NonNull Context context) {
|
||||
return new TransportOption(Type.TEXTSECURE,
|
||||
R.drawable.ic_send_lock_24,
|
||||
context.getResources().getColor(R.color.core_ultramarine),
|
||||
context.getString(R.string.ConversationActivity_transport_signal),
|
||||
context.getString(R.string.conversation_activity__type_message_push),
|
||||
new PushCharacterCalculator());
|
||||
|
||||
}
|
||||
|
||||
private @Nullable TransportOption findEnabledSmsTransportOption(Optional<Integer> subscriptionId) {
|
||||
if (subscriptionId.isPresent()) {
|
||||
final int subId = subscriptionId.get();
|
||||
|
||||
for (TransportOption transportOption : enabledTransports) {
|
||||
if (transportOption.getType() == Type.SMS &&
|
||||
subId == transportOption.getSimSubscriptionId().orElse(-1)) {
|
||||
return transportOption;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void disableTransport(Type type) {
|
||||
TransportOption selected = selectedOption.orElse(null);
|
||||
|
||||
Iterator<TransportOption> iterator = enabledTransports.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
TransportOption option = iterator.next();
|
||||
|
||||
if (option.isType(type)) {
|
||||
if (selected == option) {
|
||||
setSelectedTransport(null);
|
||||
}
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public List<TransportOption> getEnabledTransports() {
|
||||
return enabledTransports;
|
||||
}
|
||||
|
||||
public void addOnTransportChangedListener(OnTransportChangedListener listener) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
private List<TransportOption> initializeAvailableTransports(boolean isMediaMessage) {
|
||||
List<TransportOption> results = new LinkedList<>();
|
||||
|
||||
if (isMediaMessage) {
|
||||
results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_mms),
|
||||
context.getString(R.string.conversation_activity__type_message_mms_insecure),
|
||||
new MmsCharacterCalculator()));
|
||||
} else {
|
||||
results.addAll(getTransportOptionsForSimCards(context.getString(R.string.ConversationActivity_transport_insecure_sms),
|
||||
context.getString(R.string.conversation_activity__type_message_sms_insecure),
|
||||
new SmsCharacterCalculator()));
|
||||
}
|
||||
|
||||
results.add(getPushTransportOption(context));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private @NonNull List<TransportOption> getTransportOptionsForSimCards(@NonNull String text,
|
||||
@NonNull String composeHint,
|
||||
@NonNull CharacterCalculator characterCalculator)
|
||||
{
|
||||
List<TransportOption> results = new LinkedList<>();
|
||||
SubscriptionManagerCompat subscriptionManager = new SubscriptionManagerCompat(context);
|
||||
Collection<SubscriptionInfoCompat> subscriptions;
|
||||
|
||||
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE)) {
|
||||
subscriptions = subscriptionManager.getActiveAndReadySubscriptionInfos();
|
||||
} else {
|
||||
subscriptions = Collections.emptyList();
|
||||
}
|
||||
|
||||
if (subscriptions.size() < 2) {
|
||||
results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
|
||||
context.getResources().getColor(R.color.core_grey_50),
|
||||
text, composeHint, characterCalculator));
|
||||
} else {
|
||||
for (SubscriptionInfoCompat subscriptionInfo : subscriptions) {
|
||||
results.add(new TransportOption(Type.SMS, R.drawable.ic_send_unlock_24,
|
||||
context.getResources().getColor(R.color.core_grey_50),
|
||||
text, composeHint, characterCalculator,
|
||||
Optional.of(subscriptionInfo.getDisplayName()),
|
||||
Optional.of(subscriptionInfo.getSubscriptionId())));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void notifyTransportChangeListeners() {
|
||||
for (OnTransportChangedListener listener : listeners) {
|
||||
listener.onChange(getSelectedTransport(), selectedOption.isPresent());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEnabled(TransportOption transportOption) {
|
||||
for (TransportOption option : enabledTransports) {
|
||||
if (option.equals(transportOption)) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public interface OnTransportChangedListener {
|
||||
public void onChange(TransportOption newTransport, boolean manuallySelected);
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.PorterDuff.Mode;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.BaseAdapter;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class TransportOptionsAdapter extends BaseAdapter {
|
||||
|
||||
private final LayoutInflater inflater;
|
||||
|
||||
private List<TransportOption> enabledTransports;
|
||||
|
||||
public TransportOptionsAdapter(@NonNull Context context,
|
||||
@NonNull List<TransportOption> enabledTransports)
|
||||
{
|
||||
super();
|
||||
this.inflater = LayoutInflater.from(context);
|
||||
this.enabledTransports = enabledTransports;
|
||||
}
|
||||
|
||||
public void setEnabledTransports(List<TransportOption> enabledTransports) {
|
||||
this.enabledTransports = enabledTransports;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getCount() {
|
||||
return enabledTransports.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getItem(int position) {
|
||||
return enabledTransports.get(position);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getItemId(int position) {
|
||||
return position;
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getView(int position, View convertView, ViewGroup parent) {
|
||||
if (convertView == null) {
|
||||
convertView = inflater.inflate(R.layout.transport_selection_list_item, parent, false);
|
||||
}
|
||||
|
||||
TransportOption transport = (TransportOption) getItem(position);
|
||||
ImageView imageView = convertView.findViewById(R.id.icon);
|
||||
TextView textView = convertView.findViewById(R.id.text);
|
||||
TextView subtextView = convertView.findViewById(R.id.subtext);
|
||||
|
||||
imageView.getBackground().setColorFilter(transport.getBackgroundColor(), Mode.MULTIPLY);
|
||||
imageView.setImageResource(transport.getDrawable());
|
||||
textView.setText(transport.getDescription());
|
||||
|
||||
if (transport.getSimName().isPresent()) {
|
||||
subtextView.setText(transport.getSimName().get());
|
||||
subtextView.setVisibility(View.VISIBLE);
|
||||
} else {
|
||||
subtextView.setVisibility(View.GONE);
|
||||
}
|
||||
|
||||
return convertView;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package org.thoughtcrime.securesms;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ListView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.widget.ListPopupWindow;
|
||||
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
|
||||
public class TransportOptionsPopup extends ListPopupWindow implements ListView.OnItemClickListener {
|
||||
|
||||
private final TransportOptionsAdapter adapter;
|
||||
private final SelectedListener listener;
|
||||
|
||||
public TransportOptionsPopup(@NonNull Context context, @NonNull View anchor, @NonNull SelectedListener listener) {
|
||||
super(context);
|
||||
this.listener = listener;
|
||||
this.adapter = new TransportOptionsAdapter(context, new LinkedList<TransportOption>());
|
||||
|
||||
setVerticalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_yoff));
|
||||
setHorizontalOffset(context.getResources().getDimensionPixelOffset(R.dimen.transport_selection_popup_xoff));
|
||||
setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
|
||||
setModal(true);
|
||||
setAnchorView(anchor);
|
||||
setAdapter(adapter);
|
||||
setContentWidth(context.getResources().getDimensionPixelSize(R.dimen.transport_selection_popup_width));
|
||||
|
||||
setOnItemClickListener(this);
|
||||
}
|
||||
|
||||
public void display(List<TransportOption> enabledTransports) {
|
||||
adapter.setEnabledTransports(enabledTransports);
|
||||
adapter.notifyDataSetChanged();
|
||||
show();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
|
||||
listener.onSelected((TransportOption)adapter.getItem(position));
|
||||
}
|
||||
|
||||
public interface SelectedListener {
|
||||
void onSelected(TransportOption option);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
@@ -62,6 +64,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
@@ -70,6 +73,7 @@ import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequest
|
||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
@@ -105,19 +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();
|
||||
|
||||
@@ -130,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);
|
||||
@@ -155,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));
|
||||
}
|
||||
@@ -182,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);
|
||||
}
|
||||
@@ -231,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);
|
||||
}
|
||||
|
||||
@@ -253,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()
|
||||
@@ -298,7 +312,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
callScreen = findViewById(R.id.callScreen);
|
||||
callScreen.setControlsListener(new ControlsListener());
|
||||
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||
wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen);
|
||||
}
|
||||
|
||||
private void initializeViewModel(boolean isLandscapeEnabled) {
|
||||
@@ -337,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) {
|
||||
@@ -344,7 +364,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
|
||||
return;
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
|
||||
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
|
||||
SafetyNumberBottomSheet.forGroupCall(((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords())
|
||||
.show(getSupportFragmentManager());
|
||||
return;
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.SwitchToSpeaker) {
|
||||
callScreen.switchToSpeakerView();
|
||||
@@ -373,6 +394,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
videoTooltip.dismiss();
|
||||
videoTooltip = null;
|
||||
}
|
||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowWifiToCellularPopup) {
|
||||
wifiToCellularPopupWindow.show();
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unknown event: " + event);
|
||||
}
|
||||
@@ -557,11 +580,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
final Recipient recipient = event.getRemoteParticipants().get(0).getRecipient();
|
||||
|
||||
if (theirKey == null) {
|
||||
Log.w(TAG, "Untrusted identity without an identity key, terminating call.");
|
||||
handleTerminate(recipient, HangupMessage.Type.NORMAL);
|
||||
Log.w(TAG, "Untrusted identity without an identity key.");
|
||||
}
|
||||
|
||||
SafetyNumberChangeDialog.showForCall(getSupportFragmentManager(), recipient.getId());
|
||||
SafetyNumberBottomSheet.forCall(recipient.getId()).show(getSupportFragmentManager());
|
||||
}
|
||||
|
||||
public void handleSafetyNumberChangeEvent(@NonNull WebRtcCallViewModel.SafetyNumberChangeEvent safetyNumberChangeEvent) {
|
||||
@@ -570,7 +592,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.getRecipient().get());
|
||||
} else {
|
||||
GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.getRecipient().get());
|
||||
SafetyNumberChangeDialog.showForDuringGroupCall(getSupportFragmentManager(), safetyNumberChangeEvent.getRecipientIds());
|
||||
SafetyNumberBottomSheet.forDuringGroupCall(safetyNumberChangeEvent.getRecipientIds()).show(getSupportFragmentManager());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,7 +602,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
|
||||
public void handleGroupMemberCountChange(int count) {
|
||||
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
|
||||
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize();
|
||||
callScreen.enableRingGroup(canRing);
|
||||
ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
|
||||
public abstract class Attachment {
|
||||
@@ -118,8 +118,13 @@ public abstract class Attachment {
|
||||
}
|
||||
|
||||
public boolean isInProgress() {
|
||||
return transferState != AttachmentDatabase.TRANSFER_PROGRESS_DONE &&
|
||||
transferState != AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
|
||||
return transferState != AttachmentTable.TRANSFER_PROGRESS_DONE &&
|
||||
transferState != AttachmentTable.TRANSFER_PROGRESS_FAILED &&
|
||||
transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public boolean isPermanentlyFailed() {
|
||||
return transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE;
|
||||
}
|
||||
|
||||
public long getSize() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
|
||||
public class MmsNotificationAttachment extends Attachment {
|
||||
|
||||
@@ -26,14 +26,14 @@ public class MmsNotificationAttachment extends Attachment {
|
||||
}
|
||||
|
||||
private static int getTransferStateFromStatus(int status) {
|
||||
if (status == MmsDatabase.Status.DOWNLOAD_INITIALIZED ||
|
||||
status == MmsDatabase.Status.DOWNLOAD_NO_CONNECTIVITY)
|
||||
if (status == MessageTable.MmsStatus.DOWNLOAD_INITIALIZED ||
|
||||
status == MessageTable.MmsStatus.DOWNLOAD_NO_CONNECTIVITY)
|
||||
{
|
||||
return AttachmentDatabase.TRANSFER_PROGRESS_PENDING;
|
||||
} else if (status == MmsDatabase.Status.DOWNLOAD_CONNECTING) {
|
||||
return AttachmentDatabase.TRANSFER_PROGRESS_STARTED;
|
||||
return AttachmentTable.TRANSFER_PROGRESS_PENDING;
|
||||
} else if (status == MessageTable.MmsStatus.DOWNLOAD_CONNECTING) {
|
||||
return AttachmentTable.TRANSFER_PROGRESS_STARTED;
|
||||
} else {
|
||||
return AttachmentDatabase.TRANSFER_PROGRESS_FAILED;
|
||||
return AttachmentTable.TRANSFER_PROGRESS_FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
import org.thoughtcrime.securesms.util.Base64;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
@@ -68,7 +68,7 @@ public class PointerAttachment extends Attachment {
|
||||
return results;
|
||||
}
|
||||
|
||||
public static List<Attachment> forPointers(List<SignalServiceDataMessage.Quote.QuotedAttachment> pointers) {
|
||||
public static List<Attachment> forPointers(@Nullable List<SignalServiceDataMessage.Quote.QuotedAttachment> pointers) {
|
||||
List<Attachment> results = new LinkedList<>();
|
||||
|
||||
if (pointers != null) {
|
||||
@@ -102,7 +102,7 @@ public class PointerAttachment extends Attachment {
|
||||
}
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
pointer.get().asPointer().getSize().orElse(0),
|
||||
pointer.get().asPointer().getFileName().orElse(null),
|
||||
pointer.get().asPointer().getCdnNumber(),
|
||||
@@ -126,7 +126,7 @@ public class PointerAttachment extends Attachment {
|
||||
SignalServiceAttachment thumbnail = pointer.getThumbnail();
|
||||
|
||||
return Optional.of(new PointerAttachment(pointer.getContentType(),
|
||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||
AttachmentTable.TRANSFER_PROGRESS_PENDING,
|
||||
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||
pointer.getFileName(),
|
||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.net.Uri;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
|
||||
/**
|
||||
* An attachment that represents where an attachment used to be. Useful when you need to know that
|
||||
@@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
public class TombstoneAttachment extends Attachment {
|
||||
|
||||
public TombstoneAttachment(@NonNull String contentType, boolean quote) {
|
||||
super(contentType, AttachmentDatabase.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, false, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
super(contentType, AttachmentTable.TRANSFER_PROGRESS_DONE, 0, null, 0, null, null, null, null, null, false, false, false, 0, 0, quote, 0, null, null, null, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -7,9 +7,11 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import org.thoughtcrime.securesms.audio.AudioHash;
|
||||
import org.thoughtcrime.securesms.blurhash.BlurHash;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase.TransformProperties;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable.TransformProperties;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public class UriAttachment extends Attachment {
|
||||
|
||||
private final @NonNull Uri dataUri;
|
||||
@@ -51,7 +53,7 @@ public class UriAttachment extends Attachment {
|
||||
@Nullable TransformProperties transformProperties)
|
||||
{
|
||||
super(contentType, transferState, size, fileName, 0, null, null, null, null, fastPreflightId, voiceNote, borderless, videoGif, width, height, quote, 0, caption, stickerLocator, blurHash, audioHash, transformProperties);
|
||||
this.dataUri = dataUri;
|
||||
this.dataUri = Objects.requireNonNull(dataUri);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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,63 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||
@@ -100,7 +100,7 @@ public final class AudioWaveForm {
|
||||
|
||||
if (attachment instanceof DatabaseAttachment) {
|
||||
try {
|
||||
AttachmentDatabase attachmentDatabase = SignalDatabase.attachments();
|
||||
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
|
||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class TextAvatarDrawable(
|
||||
setBounds(0, 0, size, size)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun draw(canvas: Canvas) {
|
||||
val width = bounds.width()
|
||||
val textSize = Avatars.getTextSizeForLength(context, avatar.text, width * 0.8f, width * 0.45f)
|
||||
|
||||
@@ -61,6 +61,9 @@ class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), Ima
|
||||
Navigation.findNavController(requireView()).popBackStack()
|
||||
}
|
||||
|
||||
override fun restoreState() {
|
||||
}
|
||||
|
||||
override fun onMainImageLoaded() {
|
||||
}
|
||||
|
||||
|
||||
@@ -81,14 +81,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
}
|
||||
|
||||
clearButton.visible = state.canClear
|
||||
|
||||
val wasEnabled = saveButton.isEnabled
|
||||
saveButton.isEnabled = state.canSave
|
||||
if (wasEnabled != state.canSave) {
|
||||
val alpha = if (state.canSave) 1f else 0.5f
|
||||
saveButton.animate().cancel()
|
||||
saveButton.animate().alpha(alpha)
|
||||
}
|
||||
saveButton.isClickable = state.canSave
|
||||
|
||||
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
||||
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||
@@ -104,6 +97,11 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||
photoButton.setOnIconClickedListener { openGallery() }
|
||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||
saveButton.setOnClickListener { v ->
|
||||
if (!saveButton.isEnabled) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
saveButton.isEnabled = false
|
||||
viewModel.save(
|
||||
{
|
||||
setFragmentResult(
|
||||
@@ -125,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)
|
||||
|
||||
@@ -50,7 +50,7 @@ class AvatarPickerRepository(context: Context) {
|
||||
}
|
||||
|
||||
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
|
||||
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||
val recipient = Recipient.externalGroupExact(groupId)
|
||||
|
||||
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
|
||||
try {
|
||||
@@ -161,7 +161,7 @@ class AvatarPickerRepository(context: Context) {
|
||||
}
|
||||
|
||||
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
|
||||
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||
val recipient = Recipient.externalGroupExact(groupId)
|
||||
|
||||
return getDefaultAvatarForGroup(recipient.avatarColor)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -183,7 +183,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
||||
private val isNewGroup: Boolean,
|
||||
private val groupAvatarMedia: Media?
|
||||
) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
val viewModel = if (groupId == null && !isNewGroup) {
|
||||
SelfAvatarPickerViewModel(repository)
|
||||
} else if (groupId == null) {
|
||||
|
||||
@@ -33,7 +33,7 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
|
||||
}
|
||||
|
||||
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel()
|
||||
fun getCurrentAvatar() = store.state.currentAvatar
|
||||
|
||||
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
package org.thoughtcrime.securesms.backup
|
||||
|
||||
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable
|
||||
import org.thoughtcrime.securesms.database.GroupReceiptTable
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
|
||||
/**
|
||||
* Queries used by backup exporter to estimate total counts for various complicated tables.
|
||||
*/
|
||||
object BackupCountQueries {
|
||||
|
||||
const val mmsCount: String = "SELECT COUNT(*) FROM ${MmsDatabase.TABLE_NAME} WHERE ${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.VIEW_ONCE} <= 0"
|
||||
|
||||
const val smsCount: String = "SELECT COUNT(*) FROM ${SmsDatabase.TABLE_NAME} WHERE ${SmsDatabase.EXPIRES_IN} <= 0"
|
||||
const val mmsCount: String = "SELECT COUNT(*) FROM ${MessageTable.TABLE_NAME} WHERE ${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.VIEW_ONCE} <= 0"
|
||||
|
||||
@get:JvmStatic
|
||||
val groupReceiptCount: String = """
|
||||
SELECT COUNT(*) FROM ${GroupReceiptDatabase.TABLE_NAME}
|
||||
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${GroupReceiptDatabase.TABLE_NAME}.${GroupReceiptDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
|
||||
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
|
||||
SELECT COUNT(*) FROM ${GroupReceiptTable.TABLE_NAME}
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${GroupReceiptTable.TABLE_NAME}.${GroupReceiptTable.MMS_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} <= 0
|
||||
""".trimIndent()
|
||||
|
||||
@get:JvmStatic
|
||||
val attachmentCount: String = """
|
||||
SELECT COUNT(*) FROM ${AttachmentDatabase.TABLE_NAME}
|
||||
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${AttachmentDatabase.TABLE_NAME}.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
|
||||
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
|
||||
SELECT COUNT(*) FROM ${AttachmentTable.TABLE_NAME}
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MMS_ID} = ${MessageTable.TABLE_NAME}.${MessageTable.ID}
|
||||
WHERE ${MessageTable.TABLE_NAME}.${MessageTable.EXPIRES_IN} <= 0 AND ${MessageTable.TABLE_NAME}.${MessageTable.VIEW_ONCE} <= 0
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@@ -159,12 +159,12 @@ public class BackupDialog {
|
||||
public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
|
||||
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
|
||||
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
|
||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.BackupDialog_verify, null)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
AlertDialog dialog = new MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
|
||||
.setView(view)
|
||||
.setPositiveButton(R.string.BackupDialog_verify, null)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show();
|
||||
|
||||
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||
positiveButton.setEnabled(false);
|
||||
|
||||
@@ -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);
|
||||
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.FAILURES)
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(context, -1, AppSettingsActivity.backups(context), PendingIntentFlags.mutable());
|
||||
Notification backupFailedNotification = new NotificationCompat.Builder(context, NotificationChannels.getInstance().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,83 @@
|
||||
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
|
||||
@Throws(IOException::class)
|
||||
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 id: ${attachment.attachmentId} len: ${attachment.length}", 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 id: ${sticker.rowId} len: ${sticker.length}", 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 avatar id: ${avatar.recipientId} len: ${avatar.length}", 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
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user