Compare commits
975 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0fe0765e63 | |||
| 6e6752cfed | |||
| 0107e8e6eb | |||
| 7fe5376772 | |||
| 30d2d12f89 | |||
| 98ab48f0eb | |||
| a181ed0420 | |||
| dbddb274db | |||
| 8502badb6d | |||
| cb4ba1ccfe | |||
| 3b16a1d28c | |||
| ba1473acb9 | |||
| 709c866786 | |||
| ab4e5b1d7c | |||
| 7b2552e8f2 | |||
| a501940909 | |||
| a3bbf944e5 | |||
| 97d41fdd1e | |||
| a9bdc1abfc | |||
| ad626fe7ee | |||
| d97184ef60 | |||
| b527b2ffb9 | |||
| 468cda034a | |||
| 8f2c5d43df | |||
| 9bc4dfc3f6 | |||
| dc095c9db4 | |||
| ef85b29ddf | |||
| 392a66ed59 | |||
| c078d08df7 | |||
| c89b818a31 | |||
| e495c25687 | |||
| 3b2a3500a1 | |||
| d3d9b95924 | |||
| 12d1254d4e | |||
| ecc358ef40 | |||
| bb963f9210 | |||
| 820277800b | |||
| 14b2d12895 | |||
| 92a506e4da | |||
| 12e6ebb4df | |||
| c0db88960c | |||
| eeb4cdf064 | |||
| 85cecbb7e9 | |||
| 33d60ebe14 | |||
| 9afeb206fc | |||
| 06a49b5d5a | |||
| 68ba3433a3 | |||
| eaf36be9f6 | |||
| af9465fefe | |||
| 8ca0f4baf4 | |||
| 0c1edd6a56 | |||
| df88c2fd14 | |||
| c698bfca44 | |||
| 431f5501c6 | |||
| 9a20447993 | |||
| 049e5a1b99 | |||
| 4cbacc9804 | |||
| 6462d053ae | |||
| 0f08acbc04 | |||
| dc5f7d0906 | |||
| 60b20a9b8a | |||
| ec361d6349 | |||
| 1f8f1d433b | |||
| bc44704f54 | |||
| 756eafe3c8 | |||
| e770241ed4 | |||
| 4b8729c2ae | |||
| 8261e21005 | |||
| 1b1bbbab7a | |||
| 964d214434 | |||
| e2b0079a5c | |||
| 158f77a634 | |||
| 1345413645 | |||
| ee69895123 | |||
| f25f47654e | |||
| 8f52f803cf | |||
| 82d42c03f7 | |||
| c0f8e5adbf | |||
| c54c73cb48 | |||
| 02c8656b92 | |||
| 3553a28683 | |||
| acf4e97578 | |||
| 5142c8c58f | |||
| 55919cba59 | |||
| 1a6bd3d3f2 | |||
| 100dc54292 | |||
| cffbfcb957 | |||
| f73c5dde6b | |||
| d5a466851a | |||
| 38836198a1 | |||
| 52429dcd33 | |||
| fae427c09b | |||
| e22ddb8f96 | |||
| 19f0722df3 | |||
| 921f7a70b3 | |||
| bb8faebc7d | |||
| 5ed6a05eb9 | |||
| a4a4665aaa | |||
| 5d16d1cd23 | |||
| 38b6362b25 | |||
| ef0c6c79cb | |||
| f10d5651f0 | |||
| 8a2f89b4f6 | |||
| 6563ea970f | |||
| f1cb416bda | |||
| df48e5ce92 | |||
| e710e231ad | |||
| 9599d3a0b6 | |||
| 1fad4d4f65 | |||
| f57e06677b | |||
| f7b9942f11 | |||
| 2f1b05f882 | |||
| 6650f41200 | |||
| 2d8de03e05 | |||
| 9ffa866907 | |||
| 3c0c5478b5 | |||
| aae888f5af | |||
| e00a3730b4 | |||
| 5c2394aa4f | |||
| c6be273a38 | |||
| 33236ea8e6 | |||
| a6f1e0e972 | |||
| fa8f8beb56 | |||
| 11db59d8a1 | |||
| 39a11ce26c | |||
| 8bb1b2d596 | |||
| 3f1abe05fc | |||
| 08ac99b4c1 | |||
| ebf2ef65e2 | |||
| 8cb74fb776 | |||
| eccb796199 | |||
| 4635a77fbc | |||
| 9505c3d070 | |||
| 657a9c7b0a | |||
| e22560a794 | |||
| c081193373 | |||
| d23faf4278 | |||
| da7e4cefd5 | |||
| 0d9a5ef9a6 | |||
| e5aea7c49e | |||
| b8c42fa57e | |||
| 2a086ad574 | |||
| 33346d8033 | |||
| 1446af97a2 | |||
| 64b5dad783 | |||
| a6e7f9a4c1 | |||
| 70b0a120f0 | |||
| 4a6569fa1c | |||
| f5173fa6f5 | |||
| 5478285362 | |||
| e2292dfa34 | |||
| 17111abc72 | |||
| c4bc2162f2 | |||
| bfd966217f | |||
| 797c02e893 | |||
| 65372e547a | |||
| 2454b2e0db | |||
| 0505a46603 | |||
| 7f77cd6a22 | |||
| efe7b3099f | |||
| 26a831b49f | |||
| a3a5bb8177 | |||
| 4282f3eb6d | |||
| 8a49db650a | |||
| fadd4ac61e | |||
| d0c14895d0 | |||
| 32ee18240b | |||
| cd10aa90cc | |||
| 33d28c4359 | |||
| 530403ec04 | |||
| f15072bc8d | |||
| 8c2db972cf | |||
| ff8f9ca81a | |||
| 40991cc8e9 | |||
| 2f551ee3f2 | |||
| f1ab0a05f1 | |||
| fb919466de | |||
| 4a4cf08cd8 | |||
| ed20c24326 | |||
| e01cbcec62 | |||
| 04d6ccc30e | |||
| 6860f96973 | |||
| 944c8530d8 | |||
| 8b1552952c | |||
| dfcadde076 | |||
| 55acd0f048 | |||
| 820c016aad | |||
| d1d63d83dc | |||
| 7da5b2cdef | |||
| 442dde5c40 | |||
| f038e81ff3 | |||
| 32c4fcb065 | |||
| e2703b459f | |||
| 405d99fbe2 | |||
| 7b89687206 | |||
| d74f1a386c | |||
| b041ed1510 | |||
| 3426556a51 | |||
| e2cb535f3f | |||
| 3b17a41415 | |||
| 631720f111 | |||
| ab031d3dad | |||
| 6101048f07 | |||
| 115f7063d5 | |||
| 159d67ec59 | |||
| e09ce4c820 | |||
| 8cfc013960 | |||
| a436c46cb2 | |||
| 893be51810 | |||
| 97b5a49e36 | |||
| 043f06e188 | |||
| fa13b464f8 | |||
| bfaaf20fd9 | |||
| 2f97b80b9c | |||
| eee9c967fa | |||
| 515981c044 | |||
| a06528e5e1 | |||
| 98194c854a | |||
| 2d60a88a75 | |||
| c3e7d6c74c | |||
| 8da66bc789 | |||
| 9ceb5b2e85 | |||
| 17b8e086c9 | |||
| 9a097d113d | |||
| 46ca1e16bb | |||
| d4d3124a90 | |||
| 35a9fddbb2 | |||
| 41e417ff0b | |||
| f6614c1174 | |||
| 9136bcf5e8 | |||
| 7c156d10d6 | |||
| 3372d942ec | |||
| 7fc9876b1e | |||
| cff62e9528 | |||
| 24f59b0a17 | |||
| 0a07800eba | |||
| c863e9ed4d | |||
| 523537cf05 | |||
| fd4543ffe0 | |||
| 83b0309f23 | |||
| 5cabe5ecfa | |||
| fae3004512 | |||
| e143c47c25 | |||
| 27c3fca324 | |||
| 26d637cafc | |||
| 03e8fe9f27 | |||
| 23939aeee3 | |||
| d7b793ce4c | |||
| d3096c56cb | |||
| b0e7b49056 | |||
| 2f0f26c328 | |||
| 7f2f5a182f | |||
| 33b88796e8 | |||
| 31e4db2186 | |||
| 76ad7866ec | |||
| e9804eccbb | |||
| d62d0efb1d | |||
| 3ec9cd1244 | |||
| bd5f48f193 | |||
| 98fc3e5b0b | |||
| d06c633dc4 | |||
| d401386e2d | |||
| 8f0f9e64b9 | |||
| bd5ac85ac0 | |||
| 417070e957 | |||
| a92638e897 | |||
| 08abe890ff | |||
| 66b6420f21 | |||
| b21bd5a01e | |||
| d11e8ec04b | |||
| 3e5fe0f1cb | |||
| b65d62e065 | |||
| fc55be0916 | |||
| a87aa0fbe2 | |||
| a44a105cbc | |||
| c4817ac017 | |||
| 65835606cc | |||
| ff26922afb | |||
| a894ba7a51 | |||
| cb63fe600c | |||
| 2d6146351d | |||
| e5953b25e1 | |||
| 6354cb194c | |||
| 8d6beb92cb | |||
| bb5edccf34 | |||
| 6d86b25acd | |||
| 04677d21bb | |||
| 20022b88fc | |||
| 3088d7f182 | |||
| ce8dafd33d | |||
| 6054285ddb | |||
| dabea5169b | |||
| 7fb5ceeda4 | |||
| dc6fd8be7f | |||
| c271b9c2de | |||
| 6fb6092a6b | |||
| 46bb64ad24 | |||
| bcd16ce296 | |||
| 07ec14d5c4 | |||
| d716416d1d | |||
| 343871ed8b | |||
| aa60247e42 | |||
| 283e3e99a5 | |||
| ca79bdb16b | |||
| a75d2cfa34 | |||
| 1746f37276 | |||
| 73f32868a2 | |||
| dabd131222 | |||
| 612c6db6db | |||
| c56ef33833 | |||
| 2253e25ae1 | |||
| adb24d480a | |||
| 9fb1dcf28f | |||
| bba36a5724 | |||
| fa515be258 | |||
| be241524db | |||
| bb66c3fa68 | |||
| a32d5bef20 | |||
| d409278dd5 | |||
| 3328e43a40 | |||
| 678e832058 | |||
| 2c341f450f | |||
| 5854074d4a | |||
| dbe186248d | |||
| 0504161b04 | |||
| 9748f1cff8 | |||
| 6a061ed52c | |||
| 102d58502a | |||
| 477698f917 | |||
| d865b5d7b5 | |||
| eeaf6df925 | |||
| 95abca4e03 | |||
| c5906b6f3a | |||
| 91c581b475 | |||
| 47760867d5 | |||
| 5612a5d9e4 | |||
| 4c462bd75a | |||
| 092b30f64f | |||
| b34ca8ca2f | |||
| e2c54eef77 | |||
| 2a2c27edef | |||
| ec92d5ddb7 | |||
| a9f208153c | |||
| d9ffd67f36 | |||
| 19861ef0d1 | |||
| 469879c211 | |||
| 157198fd17 | |||
| 0d61b8db38 | |||
| 593334456a | |||
| 98b9cc23e4 | |||
| 3e42c044b8 | |||
| 11c3ea769e | |||
| b8bb2e234b | |||
| 87b00bb156 | |||
| 3da2fc4d9b | |||
| 972ab9b368 | |||
| c359b0134a | |||
| 2cd7462573 | |||
| 2a7d515932 | |||
| 1bb04035ab | |||
| 267efb0763 | |||
| 0ef215dfc5 | |||
| 50bea8140f | |||
| 086e3ed4ec | |||
| b02539684a | |||
| b9747607ad | |||
| 284a6ae667 | |||
| 116e711f1a | |||
| 7aeb641036 | |||
| e4f69c0b6f | |||
| 48d7228ae7 | |||
| ae28df901f | |||
| f17f45f277 | |||
| 2b15fc2966 | |||
| 76a9342afa | |||
| 14849d6e45 | |||
| 77ea2deada | |||
| 390b7ff834 | |||
| 0e4187b062 | |||
| 4098f77e08 | |||
| a60eed35fe | |||
| 904215fe38 | |||
| 6376642d38 | |||
| 6b36a446f0 | |||
| 95ee7f5c00 | |||
| b109effc94 | |||
| 44efda8318 | |||
| e6e1b6d746 | |||
| e303cbcc22 | |||
| c67aed5b65 | |||
| 0278882c30 | |||
| 42ccd638bd | |||
| e11577bd23 | |||
| 3c0b87bbca | |||
| 84a61b01ca | |||
| c149e008fd | |||
| c7a345eb0b | |||
| 348b6e9742 | |||
| 003b3e02e4 | |||
| 5b668d7931 | |||
| 87748fa80c | |||
| ad0482fb5b | |||
| 12ceb1cb32 | |||
| 9ec2c5da52 | |||
| f742d34588 | |||
| 4d8e058d33 | |||
| 77ba6e0f7b | |||
| 76a0e5c851 | |||
| f3096cc24c | |||
| 49957e1d95 | |||
| 2f5cb5f090 | |||
| ba394e1021 | |||
| 7611c64493 | |||
| 231248d20a | |||
| a3a79fc58d | |||
| 6476e585c4 | |||
| fd2961710d | |||
| 7f4ab67f98 | |||
| b9ce38b85b | |||
| 50d5658add | |||
| 72777bc6cd | |||
| f2046c3c05 | |||
| 745dfc3fbb | |||
| cb77165b53 | |||
| bde4700e87 | |||
| e58cea9a26 | |||
| 556dc0d1ec | |||
| 8c1ddcf1c0 | |||
| 2549c1f97d | |||
| 5faa497821 | |||
| d7a7e72c3a | |||
| af1701e6fa | |||
| 32d1cc7d54 | |||
| 783a615c07 | |||
| 65bfee6eba | |||
| 8d4419705b | |||
| 6c3baf229c | |||
| 6e9a6283fc | |||
| 5b3899237b | |||
| dddf830e47 | |||
| fd930d0b1d | |||
| 2b5d65ae04 | |||
| 2ebaa04c2f | |||
| 1e316ea19f | |||
| ac9257ec1c | |||
| 9b83c5e283 | |||
| a7a4972013 | |||
| f6f4e6fde7 | |||
| e9160c2449 | |||
| b0b1029d0f | |||
| 72b3a0555d | |||
| 135fde68c1 | |||
| 954e45ed97 | |||
| 2b3f16d3ad | |||
| 6820b84921 | |||
| 6a5f5f4ffa | |||
| 19381342b3 | |||
| c2627dda8d | |||
| db309b7930 | |||
| 403958fed3 | |||
| 3af53f2089 | |||
| e472760d92 | |||
| b04acd8ae0 | |||
| 6890973ce8 | |||
| b3d9a85fa2 | |||
| 9f027ed584 | |||
| 8fb598e60a | |||
| 2edaba39a0 | |||
| b0be7effe8 | |||
| 142979ce93 | |||
| 093dd7c62c | |||
| 4acafc3d77 | |||
| 65bf0aad79 | |||
| ef6e846512 | |||
| 782464f664 | |||
| c7352f62e5 | |||
| 90dd6b7cb3 | |||
| ddfb4bf0a5 | |||
| cdef21d6c0 | |||
| c0f843061e | |||
| 5774771ea6 | |||
| ab8d5474e0 | |||
| 6497ec8098 | |||
| 83c3b16b92 | |||
| 3c2bd032ba | |||
| f798866619 | |||
| ffad2c7386 | |||
| 7252e54593 | |||
| cfab4dc658 | |||
| 7f5a8ce6bb | |||
| d02a597451 | |||
| 8d92a1f195 | |||
| 74e630aacb | |||
| 65a12767f9 | |||
| ab9d813636 | |||
| 007975e7da | |||
| 86ca1ebda0 | |||
| 57fb3e6377 | |||
| 7e6fcb80a3 | |||
| 5e46e1e3d9 | |||
| 21e370de9b | |||
| 77ef877c59 | |||
| 77caedb3bb | |||
| 3e77975c17 | |||
| 75e15c81e1 | |||
| 782a1ce301 | |||
| be21b9e163 | |||
| 284140871e | |||
| e6ac40a07c | |||
| b8e98350c1 | |||
| 445ff263c6 | |||
| e10c40d2b8 | |||
| a41a2b3e64 | |||
| e603391c35 | |||
| 7e0cd99f48 | |||
| daedb8261d | |||
| 2c8744a319 | |||
| a7c441225b | |||
| e3c491860a | |||
| 43ad0b2294 | |||
| bf897d10d2 | |||
| 0b1a93d3e6 | |||
| 7edef20f4f | |||
| 945c308cf5 | |||
| f91494f813 | |||
| 9d28caac00 | |||
| 798f3a7b0e | |||
| 768e170ed4 | |||
| a0ebb891de | |||
| 570b39f82e | |||
| dc50899fe0 | |||
| 0f889e0259 | |||
| cb906edd11 | |||
| 604f6709db | |||
| 0359f27cd9 | |||
| 0ca438ed25 | |||
| 6b6e9e92e8 | |||
| b5e0991f5e | |||
| f06f0e7ae0 | |||
| 0fcbb5ffda | |||
| b1f7dbefd8 | |||
| 8fc2d5be37 | |||
| 40020728de | |||
| 4abb169568 | |||
| da1ac5358f | |||
| d504bd593a | |||
| 63e48efdfe | |||
| 8bb27b60fa | |||
| 437c1e2f21 | |||
| 7f4a12c179 | |||
| 19d3bbc70a | |||
| 559561bf72 | |||
| c8c0589ac4 | |||
| 666218773c | |||
| d3049a3433 | |||
| 130d5a8945 | |||
| 172751cd42 | |||
| 3ad7c96a3c | |||
| 6d41d1f6d2 | |||
| cb74833dc2 | |||
| 8c7b6293fb | |||
| 9d1f46da9f | |||
| 216059b659 | |||
| 18392ed0a4 | |||
| 63a4d20ea9 | |||
| 057231b9c3 | |||
| 749bbf428d | |||
| b0458f10a3 | |||
| 5b91c927b6 | |||
| b45740884b | |||
| 87ad4be117 | |||
| 78de70881f | |||
| e7a370a549 | |||
| 54eb579558 | |||
| 732b67d8cb | |||
| eed45b57a1 | |||
| 3503c60fd1 | |||
| c17ba30cfc | |||
| 5167c7235d | |||
| 803f94012a | |||
| 9281bcdd7d | |||
| 4dca554967 | |||
| 7c45fb6c17 | |||
| 8aa283488f | |||
| 604c65c7fb | |||
| 711148423d | |||
| 1f82ceecc6 | |||
| 1ac8701ada | |||
| d61e33fdf3 | |||
| e552b5160f | |||
| 7e063e8ad8 | |||
| 88a34936cd | |||
| c1181478dd | |||
| d13d8628b5 | |||
| 6048208c8c | |||
| 78214fb39b | |||
| ff8d7fa6c2 | |||
| 3a2e8b9b19 | |||
| bca4289c96 | |||
| 3fbd9baf0c | |||
| e12c96f4b2 | |||
| eec26aa481 | |||
| 865aeda6f2 | |||
| 2c4ebedda4 | |||
| 042bc8d79a | |||
| 4c7bd80f72 | |||
| 3a8591fdfb | |||
| 629aaa2093 | |||
| 5b5b118b7a | |||
| c7016aa462 | |||
| cf857e109a | |||
| 1c79840684 | |||
| 4ba7de9519 | |||
| 2eb8df347e | |||
| 9056371c41 | |||
| 1f57e1f366 | |||
| aeb568bcf4 | |||
| b7afe4411e | |||
| cba784b8ec | |||
| 3aba15e88d | |||
| fa384e93dc | |||
| 1f3e04da29 | |||
| a484d48377 | |||
| 15f51ea26e | |||
| 80bfa103ab | |||
| 66f93e0d32 | |||
| 366780f6cb | |||
| fb4c1fc268 | |||
| e3bb7ccbd3 | |||
| e3fb8a2137 | |||
| ba4c0386ef | |||
| 644945825b | |||
| 7590c6dcbb | |||
| b25cef86ee | |||
| fdaaa560e7 | |||
| 4cd438b2db | |||
| c0e1507ef4 | |||
| 8a75d78ce7 | |||
| 8176d25b4c | |||
| 21273bc165 | |||
| 213517f875 | |||
| b1c006657a | |||
| 852dcd9711 | |||
| 427e73f7fd | |||
| eae6a971e6 | |||
| 4b23e60dd6 | |||
| f0988f37f3 | |||
| e2e3617be9 | |||
| 3ac63cc59d | |||
| d935d1deca | |||
| 3b1b00027b | |||
| a1bc1aaa98 | |||
| 0ccaad1462 | |||
| ad57e62680 | |||
| 4e57432dbb | |||
| 63412b0153 | |||
| 35199abf1f | |||
| 41b5813984 | |||
| 83215bb98f | |||
| eb12395b8e | |||
| 4b07da4978 | |||
| 3fbc5423e5 | |||
| 9d9e6e2972 | |||
| 56a8451d07 | |||
| 2483a92975 | |||
| 34bbb98c96 | |||
| 155bdf6164 | |||
| 5358ed6eff | |||
| 4f3bb39e5c | |||
| 8a49534e2b | |||
| 2c3228d6df | |||
| c82d518d4d | |||
| 35cd36e9fe | |||
| ee176cbe3d | |||
| bd915cdd7f | |||
| c27f5787fe | |||
| f6cdf459bb | |||
| 4e851f90df | |||
| 8d8a2a8eef | |||
| 277c17de83 | |||
| d5fd424b95 | |||
| e701e4bff0 | |||
| 0ddfb4456b | |||
| 69dc31681d | |||
| 2d7655a6bb | |||
| fe088c39c7 | |||
| 731714d263 | |||
| c165636180 | |||
| 372dd13eba | |||
| b35ef0bb4d | |||
| bd58c91d2c | |||
| 9a5fcdbe4d | |||
| 2452056cbe | |||
| bdf7e5d367 | |||
| aae683af41 | |||
| 174cd860a0 | |||
| 765185952e | |||
| f4002850bb | |||
| 935dd7de45 | |||
| d6b6884c69 | |||
| 2ed39e4448 | |||
| 2de5ea43fb | |||
| 88d2d4d9c7 | |||
| aff0c43b39 | |||
| bd18b731c8 | |||
| 7b499f96be | |||
| 63dab3f4b0 | |||
| 80598814bd | |||
| b00abf1667 | |||
| 9594be8fcf | |||
| acecd5f013 | |||
| 2d1efb604c | |||
| a84c971cbe | |||
| 7564ef4811 | |||
| 01e75120a7 | |||
| 1314b04994 | |||
| 253cc5fec4 | |||
| c296a28a4a | |||
| ff95319559 | |||
| 3aa770ee08 | |||
| 653410cf27 | |||
| ba08dbef5f | |||
| c1df628079 | |||
| e72cac7db5 | |||
| cbfa573d3d | |||
| 1b404cef34 | |||
| cb66996407 | |||
| 96f908b068 | |||
| 472c8a441f | |||
| 1f0c56546e | |||
| 97f8b5988d | |||
| 19dc90b68b |
@@ -0,0 +1,23 @@
|
|||||||
|
# Number of days of inactivity before an Issue or Pull Request becomes stale
|
||||||
|
daysUntilStale: 60
|
||||||
|
|
||||||
|
# Number of days of inactivity before an Issue or Pull Request with the stale label is closed.
|
||||||
|
# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale.
|
||||||
|
daysUntilClose: 7
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exemptLabels:
|
||||||
|
- acknowledged
|
||||||
|
|
||||||
|
# Comment to post when marking as stale. Set to `false` to disable
|
||||||
|
markComment: >
|
||||||
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
|
for your contributions.
|
||||||
|
|
||||||
|
# Comment to post when closing a stale Issue or Pull Request.
|
||||||
|
closeComment: >
|
||||||
|
This issue has been closed due to inactivity.
|
||||||
|
|
||||||
|
# Limit the number of actions per hour, from 1-30. Default is 30
|
||||||
|
limitPerRun: 1
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- 'master'
|
- 'main'
|
||||||
- '4.**'
|
- '4.**'
|
||||||
- '5.**'
|
- '5.**'
|
||||||
|
|
||||||
@@ -14,11 +14,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: set up JDK 11
|
- name: set up JDK 11
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
|
distribution: temurin
|
||||||
java-version: 11
|
java-version: 11
|
||||||
|
|
||||||
- name: Validate Gradle Wrapper
|
- name: Validate Gradle Wrapper
|
||||||
@@ -32,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Archive reports for failed build
|
- name: Archive reports for failed build
|
||||||
if: ${{ failure() }}
|
if: ${{ failure() }}
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: reports
|
name: reports
|
||||||
path: '*/build/reports'
|
path: '*/build/reports'
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: cd reproducible-builds && docker build -t signal-android . && cd ..
|
run: cd reproducible-builds && docker build -t signal-android . && cd ..
|
||||||
|
|
||||||
- name: Test build
|
- 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,18 +1,18 @@
|
|||||||
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
#if (${PACKAGE_NAME} && ${PACKAGE_NAME} != "")package ${PACKAGE_NAME}
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import org.thoughtcrime.securesms.util.livedata.Store
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||||
|
|
||||||
#end
|
#end
|
||||||
#parse("File Header.java")
|
#parse("File Header.java")
|
||||||
class ${NAME}ViewModel : ViewModel() {
|
class ${NAME}ViewModel : ViewModel() {
|
||||||
|
|
||||||
private val store = Store(${NAME}State())
|
private val store = RxStore(${NAME}State())
|
||||||
private val disposables = CompositeDisposable()
|
private val disposables = CompositeDisposable()
|
||||||
|
|
||||||
val state: LiveData<${NAME}State> = store.stateLiveData
|
val state: Flowable<${NAME}State> = store.stateFlowable
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
disposables.clear()
|
disposables.clear()
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ https://www.transifex.com/projects/p/signal-android/
|
|||||||
|
|
||||||
## Contributing Code
|
## Contributing Code
|
||||||
|
|
||||||
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/master/CONTRIBUTING.md), that might answer some of your questions.
|
If you're new to the Signal codebase, we recommend going through our issues and picking out a simple bug to fix (check the "easy" label in our issues) in order to get yourself familiar. Also please have a look at the [CONTRIBUTING.md](https://github.com/signalapp/Signal-Android/blob/main/CONTRIBUTING.md), that might answer some of your questions.
|
||||||
|
|
||||||
For larger changes and feature ideas, we ask that you propose it on the [unofficial Community Forum](https://community.signalusers.org) for a high-level discussion with the wider community before implementation.
|
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.
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ The form and manner of this distribution makes it eligible for export under the
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright 2013-2021 Signal
|
Copyright 2013-2022 Signal
|
||||||
|
|
||||||
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
Licensed under the GPLv3: http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ try:
|
|||||||
print("\nTo include this in the distribution, copy it to the project's assets/databases/ directory.")
|
print("\nTo include this in the distribution, copy it to the project's assets/databases/ directory.")
|
||||||
print("If you support API 10 or lower, you must use the gzipped version to avoid corruption.")
|
print("If you support API 10 or lower, you must use the gzipped version to avoid corruption.")
|
||||||
|
|
||||||
except sqlite3.Error, e:
|
except sqlite3.Error as e:
|
||||||
if connection:
|
if connection:
|
||||||
connection.rollback()
|
connection.rollback()
|
||||||
print("Error: %s" % e.args[0])
|
print("Error: %s" % e.args[0])
|
||||||
|
|||||||
@@ -7,14 +7,9 @@ apply from: 'translations.gradle'
|
|||||||
apply plugin: 'org.jetbrains.kotlin.android'
|
apply plugin: 'org.jetbrains.kotlin.android'
|
||||||
apply plugin: 'app.cash.exhaustive'
|
apply plugin: 'app.cash.exhaustive'
|
||||||
apply plugin: 'kotlin-parcelize'
|
apply plugin: 'kotlin-parcelize'
|
||||||
|
apply from: 'static-ips.gradle'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
|
||||||
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
|
|
||||||
content {
|
|
||||||
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maven {
|
maven {
|
||||||
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
|
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
|
||||||
content {
|
content {
|
||||||
@@ -62,8 +57,8 @@ ktlint {
|
|||||||
version = "0.43.2"
|
version = "0.43.2"
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 1013
|
def canonicalVersionCode = 1079
|
||||||
def canonicalVersionName = "5.32.10"
|
def canonicalVersionName = "5.42.1"
|
||||||
|
|
||||||
def postFixSize = 100
|
def postFixSize = 100
|
||||||
def abiPostFix = ['universal' : 0,
|
def abiPostFix = ['universal' : 0,
|
||||||
@@ -156,6 +151,8 @@ android {
|
|||||||
exclude 'META-INF/LICENSE'
|
exclude 'META-INF/LICENSE'
|
||||||
exclude 'META-INF/NOTICE'
|
exclude 'META-INF/NOTICE'
|
||||||
exclude 'META-INF/proguard/androidx-annotations.pro'
|
exclude 'META-INF/proguard/androidx-annotations.pro'
|
||||||
|
exclude 'libsignal_jni.dylib'
|
||||||
|
exclude 'signal_jni.dll'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -178,7 +175,7 @@ android {
|
|||||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2.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_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.staging.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_SERVICE_STATUS_URL", "\"uptime.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_KEY_BACKUP_URL", "\"https://api.backup.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
buildConfigField "String", "SIGNAL_SFU_URL", "\"https://sfu.voip.signal.org\""
|
||||||
@@ -186,25 +183,28 @@ android {
|
|||||||
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
||||||
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
buildConfigField "String", "CONTENT_PROXY_HOST", "\"contentproxy.signal.org\""
|
||||||
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
buildConfigField "int", "CONTENT_PROXY_PORT", "443"
|
||||||
|
buildConfigField "String[]", "SIGNAL_SERVICE_IPS", service_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_STORAGE_IPS", storage_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_CDN_IPS", cdn_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_CDN2_IPS", cdn2_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_CDS_IPS", cds_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_KBS_IPS", kbs_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_SFU_IPS", sfu_ips
|
||||||
|
buildConfigField "String[]", "SIGNAL_CONTENT_PROXY_IPS", content_proxy_ips
|
||||||
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
buildConfigField "String", "SIGNAL_AGENT", "\"OWA\""
|
||||||
buildConfigField "String", "CDSH_PUBLIC_KEY", "\"2fe57da347cd62431528daac5fbb290730fff684afc4cfc2ed90995f58cb3b74\""
|
|
||||||
buildConfigField "String", "CDSH_CODE_HASH", "\"ec58c0d7561de8d5657f3a4b22a635eaa305204e9359dcc80a99dfd0c5f1cbf2\""
|
|
||||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||||
|
buildConfigField "String", "CDSI_MRENCLAVE", "\"42e36b74794abe612d698308b148ff8a7dc5fdc6ad28d99bc5024ed6ece18dfe\""
|
||||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"0cedba03535b41b67729ce9924185f831d7767928a1d1689acb689bc079c375f\", " +
|
||||||
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
"\"187d2739d22be65e74b65f0055e74d31310e4267e5fac2b1246cc8beba81af39\", " +
|
||||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||||
"new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
|
||||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
|
||||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
|
||||||
"}"
|
|
||||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BXu6QIKVz5MA8gstzfOgRQGqyLqOwNKHL6INkv3IHWMF\""
|
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+ikU5gBXsRmoF94GXQ==\""
|
||||||
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
buildConfigField "String[]", "LANGUAGES", "new String[]{\"" + autoResConfig().collect { s -> s.replace('-r', '_') }.join('", "') + '"}'
|
||||||
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
buildConfigField "int", "CANONICAL_VERSION_CODE", "$canonicalVersionCode"
|
||||||
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
buildConfigField "String", "DEFAULT_CURRENCIES", "\"EUR,AUD,GBP,CAD,CNY\""
|
||||||
buildConfigField "int[]", "MOBILE_COIN_BLACKLIST", "new int[]{98,963,53,850,7}"
|
|
||||||
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
buildConfigField "String", "GIPHY_API_KEY", "\"3o6ZsYH6U6Eri53TXy\""
|
||||||
|
buildConfigField "String", "SIGNAL_CAPTCHA_URL", "\"https://signalcaptchas.org/registration/generate.html\""
|
||||||
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
|
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/challenge/generate.html\""
|
||||||
|
|
||||||
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
|
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"unset\""
|
||||||
@@ -335,14 +335,11 @@ android {
|
|||||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"dd6f66d397d9e8cf6ec6db238e59a7be078dd50e9715427b9c89b409ffe53f99\", " +
|
||||||
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
"\"4200003414528c151e2dccafbc87aa6d3d66a5eb8f8c05979a6e97cb33cd493a\", " +
|
||||||
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
"\"ee19f1965b1eefa3dc4204eb70c04f397755f771b8c1909d080c04dad2a6a9ba\")"
|
||||||
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[] {" +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||||
"new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
|
||||||
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
|
||||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")" +
|
|
||||||
"}"
|
|
||||||
buildConfigField "String", "UNIDENTIFIED_SENDER_TRUST_ROOT", "\"BbqY1DzohE4NUZoVF+L18oUPrK3kILllLEJh2UnPSsEx\""
|
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+UwyIlIT9L7KLS19Aw8r4sPrXQ==\""
|
||||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"testnet\""
|
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\""
|
buildConfigField "String", "RECAPTCHA_PROOF_URL", "\"https://signalcaptchas.org/staging/challenge/generate.html\""
|
||||||
|
|
||||||
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Staging\""
|
||||||
@@ -445,8 +442,10 @@ dependencies {
|
|||||||
implementation project(':device-transfer')
|
implementation project(':device-transfer')
|
||||||
implementation project(':image-editor')
|
implementation project(':image-editor')
|
||||||
implementation project(':donations')
|
implementation project(':donations')
|
||||||
|
implementation project(':contacts')
|
||||||
|
implementation project(':qr')
|
||||||
|
|
||||||
implementation libs.signal.client.android
|
implementation libs.libsignal.android
|
||||||
implementation libs.google.protobuf.javalite
|
implementation libs.google.protobuf.javalite
|
||||||
|
|
||||||
implementation(libs.mobilecoin) {
|
implementation(libs.mobilecoin) {
|
||||||
@@ -471,7 +470,6 @@ dependencies {
|
|||||||
implementation libs.materialish.progress
|
implementation libs.materialish.progress
|
||||||
implementation libs.greenrobot.eventbus
|
implementation libs.greenrobot.eventbus
|
||||||
implementation libs.waitingdots
|
implementation libs.waitingdots
|
||||||
implementation libs.floatingactionbutton
|
|
||||||
implementation libs.google.zxing.android.integration
|
implementation libs.google.zxing.android.integration
|
||||||
implementation libs.time.duration.picker
|
implementation libs.time.duration.picker
|
||||||
implementation libs.google.zxing.core
|
implementation libs.google.zxing.core
|
||||||
@@ -497,7 +495,6 @@ dependencies {
|
|||||||
implementation libs.lottie
|
implementation libs.lottie
|
||||||
|
|
||||||
implementation libs.stickyheadergrid
|
implementation libs.stickyheadergrid
|
||||||
implementation libs.circular.progress.button
|
|
||||||
|
|
||||||
implementation libs.signal.android.database.sqlcipher
|
implementation libs.signal.android.database.sqlcipher
|
||||||
implementation libs.androidx.sqlite
|
implementation libs.androidx.sqlite
|
||||||
@@ -514,10 +511,7 @@ dependencies {
|
|||||||
testImplementation testLibs.junit.junit
|
testImplementation testLibs.junit.junit
|
||||||
testImplementation testLibs.assertj.core
|
testImplementation testLibs.assertj.core
|
||||||
testImplementation testLibs.mockito.core
|
testImplementation testLibs.mockito.core
|
||||||
testImplementation testLibs.powermock.api.mockito
|
testImplementation testLibs.mockito.kotlin
|
||||||
testImplementation testLibs.powermock.module.junit4.core
|
|
||||||
testImplementation testLibs.powermock.module.junit4.rule
|
|
||||||
testImplementation testLibs.powermock.classloading.xstream
|
|
||||||
|
|
||||||
testImplementation testLibs.androidx.test.core
|
testImplementation testLibs.androidx.test.core
|
||||||
testImplementation (testLibs.robolectric.robolectric) {
|
testImplementation (testLibs.robolectric.robolectric) {
|
||||||
@@ -533,6 +527,9 @@ dependencies {
|
|||||||
|
|
||||||
androidTestImplementation testLibs.androidx.test.ext.junit
|
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||||
androidTestImplementation testLibs.espresso.core
|
androidTestImplementation testLibs.espresso.core
|
||||||
|
androidTestImplementation testLibs.androidx.test.core
|
||||||
|
androidTestImplementation testLibs.androidx.test.core.ktx
|
||||||
|
androidTestImplementation testLibs.androidx.test.ext.junit.ktx
|
||||||
|
|
||||||
testImplementation testLibs.espresso.core
|
testImplementation testLibs.espresso.core
|
||||||
|
|
||||||
@@ -544,7 +541,7 @@ dependencies {
|
|||||||
implementation libs.rxjava3.rxkotlin
|
implementation libs.rxjava3.rxkotlin
|
||||||
implementation libs.rxdogtag
|
implementation libs.rxdogtag
|
||||||
|
|
||||||
androidTestUtil 'androidx.test:orchestrator:1.4.0'
|
androidTestUtil 'androidx.test:orchestrator:1.4.1'
|
||||||
}
|
}
|
||||||
|
|
||||||
def getLastCommitTimestamp() {
|
def getLastCommitTimestamp() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
-dontobfuscate
|
-dontobfuscate
|
||||||
-keepattributes SourceFile,LineNumberTable
|
-keepattributes SourceFile,LineNumberTable
|
||||||
-keep class org.whispersystems.** { *; }
|
-keep class org.whispersystems.** { *; }
|
||||||
|
-keep class org.signal.libsignal.protocol.** { *; }
|
||||||
-keep class org.thoughtcrime.securesms.** { *; }
|
-keep class org.thoughtcrime.securesms.** { *; }
|
||||||
-keepclassmembers class ** {
|
-keepclassmembers class ** {
|
||||||
public void onEvent*(**);
|
public void onEvent*(**);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.thoughtcrime.securesms.conversation
|
||||||
|
|
||||||
|
import androidx.test.core.app.ActivityScenario
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
|
||||||
|
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.profiles.ProfileName
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.testing.SignalActivityRule
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android test to help show SNC dialog quickly with custom data to make sure it displays properly.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SafetyNumberChangeDialogPreviewer {
|
||||||
|
|
||||||
|
@get:Rule val harness = SignalActivityRule()
|
||||||
|
|
||||||
|
@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, IdentityDatabase.VerifiedStatus.VERIFIED)
|
||||||
|
harness.changeIdentityKey(other)
|
||||||
|
|
||||||
|
val scenario: ActivityScenario<ConversationActivity> = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) }
|
||||||
|
scenario.onActivity {
|
||||||
|
SafetyNumberChangeDialog.show(it.supportFragmentManager, other.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately
|
||||||
|
// ThreadUtil.sleep(15000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||||
|
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||||
|
import org.thoughtcrime.securesms.database.model.StoryType
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
import org.whispersystems.signalservice.api.push.ACI
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DistributionListDatabaseTest {
|
||||||
|
|
||||||
|
private lateinit var distributionDatabase: DistributionListDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
distributionDatabase = SignalDatabase.distributionLists
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createList_whenNoConflict_insertSuccessfully() {
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createList_whenNameConflict_failToInsert() {
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
|
||||||
|
val id2: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||||
|
Assert.assertNull(id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getList_returnCorrectList() {
|
||||||
|
createRecipients(3)
|
||||||
|
val members: List<RecipientId> = recipientList(1, 2, 3)
|
||||||
|
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", members)
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
|
||||||
|
val record: DistributionListRecord? = distributionDatabase.getList(id!!)
|
||||||
|
Assert.assertNotNull(record)
|
||||||
|
Assert.assertEquals(id, record!!.id)
|
||||||
|
Assert.assertEquals("test", record.name)
|
||||||
|
Assert.assertEquals(members, record.members)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getMembers_returnsCorrectMembers() {
|
||||||
|
createRecipients(3)
|
||||||
|
val members: List<RecipientId> = recipientList(1, 2, 3)
|
||||||
|
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", members)
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
|
||||||
|
val foundMembers: List<RecipientId> = distributionDatabase.getMembers(id!!)
|
||||||
|
Assert.assertEquals(members, foundMembers)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenStoryExists_getStoryType_returnsStoryWithReplies() {
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
|
||||||
|
val storyType = distributionDatabase.getStoryType(id!!)
|
||||||
|
Assert.assertEquals(StoryType.STORY_WITH_REPLIES, storyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenStoryExistsAndMarkedNoReplies_getStoryType_returnsStoryWithoutReplies() {
|
||||||
|
val id: DistributionListId? = distributionDatabase.createList("test", recipientList(1, 2, 3))
|
||||||
|
Assert.assertNotNull(id)
|
||||||
|
distributionDatabase.setAllowsReplies(id!!, false)
|
||||||
|
|
||||||
|
val storyType = distributionDatabase.getStoryType(id)
|
||||||
|
Assert.assertEquals(StoryType.STORY_WITHOUT_REPLIES, storyType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalStateException::class)
|
||||||
|
fun givenStoryDoesNotExist_getStoryType_throwsIllegalStateException() {
|
||||||
|
distributionDatabase.getStoryType(DistributionListId.from(12))
|
||||||
|
Assert.fail("Expected an assertion error.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRecipients(count: Int) {
|
||||||
|
for (i in 0 until count) {
|
||||||
|
SignalDatabase.recipients.getOrInsertFromServiceId(ACI.from(UUID.randomUUID()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recipientList(vararg ids: Long): List<RecipientId> {
|
||||||
|
return ids.map { RecipientId.from(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 MmsDatabaseTest_gifts {
|
||||||
|
private lateinit var mms: MmsDatabase
|
||||||
|
|
||||||
|
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.mms
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,
|
||||||
|
threadId = -1L
|
||||||
|
)
|
||||||
|
|
||||||
|
// WHEN
|
||||||
|
val result = mms.isOutgoingStoryAlreadyInDatabase(myStory.id, 200)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
assertTrue(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
|
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.recipients.Recipient
|
||||||
|
import java.util.Optional
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper methods for inserting an MMS message into the MMS table.
|
||||||
|
*/
|
||||||
|
object MmsHelper {
|
||||||
|
|
||||||
|
fun insert(
|
||||||
|
recipient: Recipient = Recipient.UNKNOWN,
|
||||||
|
body: String = "body",
|
||||||
|
sentTimeMillis: Long = System.currentTimeMillis(),
|
||||||
|
subscriptionId: Int = -1,
|
||||||
|
expiresIn: Long = 0,
|
||||||
|
viewOnce: Boolean = false,
|
||||||
|
distributionType: Int = ThreadDatabase.DistributionTypes.DEFAULT,
|
||||||
|
threadId: Long = 1,
|
||||||
|
storyType: StoryType = StoryType.NONE,
|
||||||
|
giftBadge: GiftBadge? = null
|
||||||
|
): Long {
|
||||||
|
val message = OutgoingMediaMessage(
|
||||||
|
recipient,
|
||||||
|
body,
|
||||||
|
emptyList(),
|
||||||
|
sentTimeMillis,
|
||||||
|
subscriptionId,
|
||||||
|
expiresIn,
|
||||||
|
viewOnce,
|
||||||
|
distributionType,
|
||||||
|
storyType,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
emptyList(),
|
||||||
|
emptySet(),
|
||||||
|
emptySet(),
|
||||||
|
giftBadge
|
||||||
|
)
|
||||||
|
|
||||||
|
return insert(
|
||||||
|
message = message,
|
||||||
|
threadId = threadId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insert(
|
||||||
|
message: OutgoingMediaMessage,
|
||||||
|
threadId: Long
|
||||||
|
): Long {
|
||||||
|
return SignalDatabase.mms.insertMessageOutbox(message, threadId, false, GroupReceiptDatabase.STATUS_UNKNOWN, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun insert(
|
||||||
|
message: IncomingMediaMessage,
|
||||||
|
threadId: Long
|
||||||
|
): Optional<MessageDatabase.InsertResult> {
|
||||||
|
return SignalDatabase.mms.insertSecureDecryptedMessageInbox(message, threadId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.thoughtcrime.securesms.database
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
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.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNotEquals
|
import org.junit.Assert.assertNotEquals
|
||||||
@@ -8,26 +11,55 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import org.signal.core.util.CursorUtil
|
||||||
import org.signal.core.util.ThreadUtil
|
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.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
|
import org.thoughtcrime.securesms.jobs.RecipientChangedNumberJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
||||||
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||||
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
|
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
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.Recipient
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
import org.whispersystems.libsignal.util.guava.Optional
|
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||||
import org.whispersystems.signalservice.api.push.ACI
|
import org.whispersystems.signalservice.api.push.ACI
|
||||||
import org.whispersystems.signalservice.api.push.PNI
|
import org.whispersystems.signalservice.api.push.PNI
|
||||||
import java.lang.IllegalArgumentException
|
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||||
|
import java.util.Optional
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class RecipientDatabaseTest {
|
class RecipientDatabaseTest_getAndPossiblyMerge {
|
||||||
|
|
||||||
private lateinit var recipientDatabase: RecipientDatabase
|
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 localAci = ACI.from(UUID.randomUUID())
|
||||||
private val localPni = PNI.from(UUID.randomUUID())
|
private val localPni = PNI.from(UUID.randomUUID())
|
||||||
@@ -35,6 +67,19 @@ class RecipientDatabaseTest {
|
|||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
recipientDatabase = SignalDatabase.recipients
|
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()
|
ensureDbEmpty()
|
||||||
|
|
||||||
SignalStore.account().setAci(localAci)
|
SignalStore.account().setAci(localAci)
|
||||||
@@ -479,6 +524,140 @@ class RecipientDatabaseTest {
|
|||||||
assert(changeNumberListener.numberChangeWasEnqueued)
|
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
|
// Misc
|
||||||
// ==============================================================
|
// ==============================================================
|
||||||
@@ -523,12 +702,59 @@ class RecipientDatabaseTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureDbEmpty() {
|
private fun ensureDbEmpty() {
|
||||||
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
|
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME} WHERE ${RecipientDatabase.DISTRIBUTION_LIST_ID} IS NULL ", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(0, cursor.getLong(0))
|
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 {
|
private class ChangeNumberListener {
|
||||||
|
|
||||||
var numberChangeWasEnqueued = false
|
var numberChangeWasEnqueued = false
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms.database
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import org.hamcrest.MatcherAssert.assertThat
|
|
||||||
import org.hamcrest.Matchers
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup
|
|
||||||
import org.signal.storageservice.protos.groups.local.DecryptedMember
|
|
||||||
import org.signal.zkgroup.groups.GroupMasterKey
|
|
||||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
|
||||||
import org.thoughtcrime.securesms.database.model.Mention
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageId
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
|
||||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
|
||||||
import org.thoughtcrime.securesms.groups.GroupId
|
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
|
||||||
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
|
|
||||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
|
||||||
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
|
||||||
import org.thoughtcrime.securesms.util.CursorUtil
|
|
||||||
import org.whispersystems.libsignal.IdentityKey
|
|
||||||
import org.whispersystems.libsignal.SignalProtocolAddress
|
|
||||||
import org.whispersystems.libsignal.state.SessionRecord
|
|
||||||
import org.whispersystems.libsignal.util.guava.Optional
|
|
||||||
import org.whispersystems.signalservice.api.push.ACI
|
|
||||||
import org.whispersystems.signalservice.api.push.PNI
|
|
||||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class RecipientDatabaseTest_merges {
|
|
||||||
|
|
||||||
private lateinit var recipientDatabase: RecipientDatabase
|
|
||||||
private lateinit var identityDatabase: IdentityDatabase
|
|
||||||
private lateinit var groupReceiptDatabase: GroupReceiptDatabase
|
|
||||||
private lateinit var groupDatabase: GroupDatabase
|
|
||||||
private lateinit var threadDatabase: ThreadDatabase
|
|
||||||
private lateinit var smsDatabase: MessageDatabase
|
|
||||||
private lateinit var mmsDatabase: MessageDatabase
|
|
||||||
private lateinit var sessionDatabase: SessionDatabase
|
|
||||||
private lateinit var mentionDatabase: MentionDatabase
|
|
||||||
private lateinit var reactionDatabase: ReactionDatabase
|
|
||||||
private lateinit var notificationProfileDatabase: NotificationProfileDatabase
|
|
||||||
|
|
||||||
private val localAci = ACI.from(UUID.randomUUID())
|
|
||||||
private val localPni = PNI.from(UUID.randomUUID())
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
recipientDatabase = SignalDatabase.recipients
|
|
||||||
identityDatabase = SignalDatabase.identities
|
|
||||||
groupReceiptDatabase = SignalDatabase.groupReceipts
|
|
||||||
groupDatabase = SignalDatabase.groups
|
|
||||||
threadDatabase = SignalDatabase.threads
|
|
||||||
smsDatabase = SignalDatabase.sms
|
|
||||||
mmsDatabase = SignalDatabase.mms
|
|
||||||
sessionDatabase = SignalDatabase.sessions
|
|
||||||
mentionDatabase = SignalDatabase.mentions
|
|
||||||
reactionDatabase = SignalDatabase.reactions
|
|
||||||
notificationProfileDatabase = SignalDatabase.notificationProfiles
|
|
||||||
|
|
||||||
SignalStore.account().setAci(localAci)
|
|
||||||
SignalStore.account().setPni(localPni)
|
|
||||||
|
|
||||||
ensureDbEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
|
||||||
@Test
|
|
||||||
fun getAndPossiblyMerge_general() {
|
|
||||||
// Setup
|
|
||||||
val recipientIdAci: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_A)
|
|
||||||
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
|
||||||
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromServiceId(ACI_B)
|
|
||||||
|
|
||||||
val smsId1: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 0, body = "0")).get().messageId
|
|
||||||
val smsId2: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdE164, time = 1, body = "1")).get().messageId
|
|
||||||
val smsId3: Long = smsDatabase.insertMessageInbox(smsMessage(sender = recipientIdAci, time = 2, body = "2")).get().messageId
|
|
||||||
|
|
||||||
val mmsId1: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 3, body = "3"), -1).get().messageId
|
|
||||||
val mmsId2: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdE164, time = 4, body = "4"), -1).get().messageId
|
|
||||||
val mmsId3: Long = mmsDatabase.insertSecureDecryptedMessageInbox(mmsMessage(sender = recipientIdAci, time = 5, body = "5"), -1).get().messageId
|
|
||||||
|
|
||||||
val threadIdAci: Long = threadDatabase.getThreadIdFor(recipientIdAci)!!
|
|
||||||
val threadIdE164: Long = threadDatabase.getThreadIdFor(recipientIdE164)!!
|
|
||||||
assertNotEquals(threadIdAci, threadIdE164)
|
|
||||||
|
|
||||||
mentionDatabase.insert(threadIdAci, mmsId1, listOf(Mention(recipientIdE164, 0, 1)))
|
|
||||||
mentionDatabase.insert(threadIdE164, mmsId2, listOf(Mention(recipientIdAci, 0, 1)))
|
|
||||||
|
|
||||||
groupReceiptDatabase.insert(listOf(recipientIdAci, recipientIdE164), mmsId1, 0, 3)
|
|
||||||
|
|
||||||
val identityKeyAci: IdentityKey = identityKey(1)
|
|
||||||
val identityKeyE164: IdentityKey = identityKey(2)
|
|
||||||
|
|
||||||
identityDatabase.saveIdentity(ACI_A.toString(), recipientIdAci, identityKeyAci, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
|
||||||
identityDatabase.saveIdentity(E164_A, recipientIdE164, identityKeyE164, IdentityDatabase.VerifiedStatus.VERIFIED, false, 0, false)
|
|
||||||
|
|
||||||
sessionDatabase.store(localAci, SignalProtocolAddress(ACI_A.toString(), 1), SessionRecord())
|
|
||||||
|
|
||||||
reactionDatabase.addReaction(MessageId(smsId1, false), ReactionRecord("a", recipientIdAci, 1, 1))
|
|
||||||
reactionDatabase.addReaction(MessageId(mmsId1, true), ReactionRecord("b", recipientIdE164, 1, 1))
|
|
||||||
|
|
||||||
val profile1: NotificationProfile = notificationProfile(name = "Test")
|
|
||||||
val profile2: NotificationProfile = notificationProfile(name = "Test2")
|
|
||||||
|
|
||||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdAci)
|
|
||||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile1.id, recipientId = recipientIdE164)
|
|
||||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdE164)
|
|
||||||
notificationProfileDatabase.addAllowedRecipient(profileId = profile2.id, recipientId = recipientIdAciB)
|
|
||||||
|
|
||||||
// Merge
|
|
||||||
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
|
||||||
val retrievedThreadId: Long = threadDatabase.getThreadIdFor(retrievedId)!!
|
|
||||||
assertEquals(recipientIdAci, retrievedId)
|
|
||||||
|
|
||||||
// Recipient validation
|
|
||||||
val retrievedRecipient = Recipient.resolved(retrievedId)
|
|
||||||
assertEquals(ACI_A, retrievedRecipient.requireServiceId())
|
|
||||||
assertEquals(E164_A, retrievedRecipient.requireE164())
|
|
||||||
|
|
||||||
val existingE164Recipient = Recipient.resolved(recipientIdE164)
|
|
||||||
assertEquals(retrievedId, existingE164Recipient.id)
|
|
||||||
|
|
||||||
// Thread validation
|
|
||||||
assertEquals(threadIdAci, retrievedThreadId)
|
|
||||||
assertNull(threadDatabase.getThreadIdFor(recipientIdE164))
|
|
||||||
assertNull(threadDatabase.getThreadRecord(threadIdE164))
|
|
||||||
|
|
||||||
// SMS validation
|
|
||||||
val sms1: MessageRecord = smsDatabase.getMessageRecord(smsId1)!!
|
|
||||||
val sms2: MessageRecord = smsDatabase.getMessageRecord(smsId2)!!
|
|
||||||
val sms3: MessageRecord = smsDatabase.getMessageRecord(smsId3)!!
|
|
||||||
|
|
||||||
assertEquals(retrievedId, sms1.recipient.id)
|
|
||||||
assertEquals(retrievedId, sms2.recipient.id)
|
|
||||||
assertEquals(retrievedId, sms3.recipient.id)
|
|
||||||
|
|
||||||
assertEquals(retrievedThreadId, sms1.threadId)
|
|
||||||
assertEquals(retrievedThreadId, sms2.threadId)
|
|
||||||
assertEquals(retrievedThreadId, sms3.threadId)
|
|
||||||
|
|
||||||
// MMS validation
|
|
||||||
val mms1: MessageRecord = mmsDatabase.getMessageRecord(mmsId1)!!
|
|
||||||
val mms2: MessageRecord = mmsDatabase.getMessageRecord(mmsId2)!!
|
|
||||||
val mms3: MessageRecord = mmsDatabase.getMessageRecord(mmsId3)!!
|
|
||||||
|
|
||||||
assertEquals(retrievedId, mms1.recipient.id)
|
|
||||||
assertEquals(retrievedId, mms2.recipient.id)
|
|
||||||
assertEquals(retrievedId, mms3.recipient.id)
|
|
||||||
|
|
||||||
assertEquals(retrievedThreadId, mms1.threadId)
|
|
||||||
assertEquals(retrievedThreadId, mms2.threadId)
|
|
||||||
assertEquals(retrievedThreadId, mms3.threadId)
|
|
||||||
|
|
||||||
// Mention validation
|
|
||||||
val mention1: MentionModel = getMention(mmsId1)
|
|
||||||
assertEquals(retrievedId, mention1.recipientId)
|
|
||||||
assertEquals(retrievedThreadId, mention1.threadId)
|
|
||||||
|
|
||||||
val mention2: MentionModel = getMention(mmsId2)
|
|
||||||
assertEquals(retrievedId, mention2.recipientId)
|
|
||||||
assertEquals(retrievedThreadId, mention2.threadId)
|
|
||||||
|
|
||||||
// Group receipt validation
|
|
||||||
val groupReceipts: List<GroupReceiptDatabase.GroupReceiptInfo> = groupReceiptDatabase.getGroupReceiptInfo(mmsId1)
|
|
||||||
assertEquals(retrievedId, groupReceipts[0].recipientId)
|
|
||||||
assertEquals(retrievedId, groupReceipts[1].recipientId)
|
|
||||||
|
|
||||||
// Identity validation
|
|
||||||
assertEquals(identityKeyAci, identityDatabase.getIdentityStoreRecord(ACI_A.toString())!!.identityKey)
|
|
||||||
assertNull(identityDatabase.getIdentityStoreRecord(E164_A))
|
|
||||||
|
|
||||||
// Session validation
|
|
||||||
assertNotNull(sessionDatabase.load(localAci, SignalProtocolAddress(ACI_A.toString(), 1)))
|
|
||||||
|
|
||||||
// Reaction validation
|
|
||||||
val reactionsSms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(smsId1, false))
|
|
||||||
val reactionsMms: List<ReactionRecord> = reactionDatabase.getReactions(MessageId(mmsId1, true))
|
|
||||||
|
|
||||||
assertEquals(1, reactionsSms.size)
|
|
||||||
assertEquals(ReactionRecord("a", recipientIdAci, 1, 1), reactionsSms[0])
|
|
||||||
|
|
||||||
assertEquals(1, reactionsMms.size)
|
|
||||||
assertEquals(ReactionRecord("b", recipientIdAci, 1, 1), reactionsMms[0])
|
|
||||||
|
|
||||||
// Notification Profile validation
|
|
||||||
val updatedProfile1: NotificationProfile = notificationProfileDatabase.getProfile(profile1.id)!!
|
|
||||||
val updatedProfile2: NotificationProfile = notificationProfileDatabase.getProfile(profile2.id)!!
|
|
||||||
|
|
||||||
assertThat("Notification Profile 1 should now only contain ACI $recipientIdAci", updatedProfile1.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci))
|
|
||||||
assertThat("Notification Profile 2 should now contain ACI A ($recipientIdAci) and ACI B ($recipientIdAciB)", updatedProfile2.allowedMembers, Matchers.containsInAnyOrder(recipientIdAci, recipientIdAciB))
|
|
||||||
}
|
|
||||||
|
|
||||||
private val context: Application
|
|
||||||
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as Application
|
|
||||||
|
|
||||||
private fun ensureDbEmpty() {
|
|
||||||
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", 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.absent()): 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.absent()): IncomingMediaMessage {
|
|
||||||
return IncomingMediaMessage(sender, groupId, body, time, time, time, emptyList(), 0, 0, false, false, true, Optional.absent())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun identityKey(value: Byte): IdentityKey {
|
|
||||||
val bytes = ByteArray(33)
|
|
||||||
bytes[0] = 0x05
|
|
||||||
bytes[1] = value
|
|
||||||
return IdentityKey(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun groupMasterKey(value: Byte): GroupMasterKey {
|
|
||||||
val bytes = ByteArray(32)
|
|
||||||
bytes[0] = value
|
|
||||||
return GroupMasterKey(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun decryptedGroup(members: Collection<UUID>): DecryptedGroup {
|
|
||||||
return DecryptedGroup.newBuilder()
|
|
||||||
.addAllMembers(members.map { DecryptedMember.newBuilder().setUuid(UuidUtil.toByteString(it)).build() })
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMention(messageId: Long): MentionModel {
|
|
||||||
SignalDatabase.rawDatabase.rawQuery("SELECT * FROM ${MentionDatabase.TABLE_NAME} WHERE ${MentionDatabase.MESSAGE_ID} = $messageId").use { cursor ->
|
|
||||||
cursor.moveToFirst()
|
|
||||||
return MentionModel(
|
|
||||||
recipientId = RecipientId.from(CursorUtil.requireLong(cursor, MentionDatabase.RECIPIENT_ID)),
|
|
||||||
threadId = CursorUtil.requireLong(cursor, MentionDatabase.THREAD_ID)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun notificationProfile(name: String): NotificationProfile {
|
|
||||||
return (notificationProfileDatabase.createProfile(name = name, emoji = "", color = AvatarColor.A210, System.currentTimeMillis()) as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The normal mention model doesn't have a threadId, so we need to do it ourselves for the test */
|
|
||||||
data class MentionModel(
|
|
||||||
val recipientId: RecipientId,
|
|
||||||
val threadId: Long
|
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val ACI_A = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
|
||||||
val ACI_B = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
|
||||||
|
|
||||||
val E164_A = "+12221234567"
|
|
||||||
val E164_B = "+13331234567"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.signal.core.util.requireLong
|
||||||
|
import org.signal.core.util.requireString
|
||||||
|
import org.signal.core.util.select
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.recipients.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
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class RecipientDatabaseTest_processCdsV2Result {
|
||||||
|
|
||||||
|
private lateinit var recipientDatabase: RecipientDatabase
|
||||||
|
|
||||||
|
private val localAci = ACI.from(UUID.randomUUID())
|
||||||
|
private val localPni = PNI.from(UUID.randomUUID())
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
recipientDatabase = SignalDatabase.recipients
|
||||||
|
|
||||||
|
ensureDbEmpty()
|
||||||
|
|
||||||
|
SignalStore.account().setAci(localAci)
|
||||||
|
SignalStore.account().setPni(localPni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_noMatch() {
|
||||||
|
// Note that we haven't inserted any test data
|
||||||
|
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(resultId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_fullMatch() {
|
||||||
|
val inputId: RecipientId = insert(E164_A, PNI_A, ACI_A)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_onlyE164Matches() {
|
||||||
|
val inputId: RecipientId = insert(E164_A, null, null)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_e164AndPniMatches() {
|
||||||
|
val inputId: RecipientId = insert(E164_A, PNI_A, null)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_e164AndAciMatches() {
|
||||||
|
val inputId: RecipientId = insert(E164_A, null, ACI_A)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_onlyPniMatches() {
|
||||||
|
val inputId: RecipientId = insert(null, PNI_A, null)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_pniAndAciMatches() {
|
||||||
|
val inputId: RecipientId = insert(null, PNI_A, ACI_A)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processCdsV2Result_onlyAciMatches() {
|
||||||
|
val inputId: RecipientId = insert(null, null, ACI_A)
|
||||||
|
val resultId: RecipientId = recipientDatabase.processCdsV2Result(E164_A, PNI_A, ACI_A)
|
||||||
|
|
||||||
|
val record: IdRecord = require(resultId)
|
||||||
|
|
||||||
|
assertEquals(inputId, record.id)
|
||||||
|
assertEquals(E164_A, record.e164)
|
||||||
|
assertEquals(ACI_A, record.sid)
|
||||||
|
assertEquals(PNI_A, record.pni)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insert(e164: String?, pni: PNI?, aci: ACI?): RecipientId {
|
||||||
|
val id: Long = SignalDatabase.rawDatabase.insert(
|
||||||
|
RecipientDatabase.TABLE_NAME,
|
||||||
|
null,
|
||||||
|
contentValuesOf(
|
||||||
|
RecipientDatabase.PHONE to e164,
|
||||||
|
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
|
||||||
|
RecipientDatabase.PNI_COLUMN to pni?.toString(),
|
||||||
|
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecipientId.from(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun require(id: RecipientId): IdRecord {
|
||||||
|
return get(id)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun get(id: RecipientId): IdRecord? {
|
||||||
|
SignalDatabase.rawDatabase
|
||||||
|
.select(RecipientDatabase.ID, RecipientDatabase.PHONE, RecipientDatabase.SERVICE_ID, RecipientDatabase.PNI_COLUMN)
|
||||||
|
.from(RecipientDatabase.TABLE_NAME)
|
||||||
|
.where("${RecipientDatabase.ID} = ?", id)
|
||||||
|
.run()
|
||||||
|
.use { cursor ->
|
||||||
|
return if (cursor.moveToFirst()) {
|
||||||
|
IdRecord(
|
||||||
|
id = RecipientId.from(cursor.requireLong(RecipientDatabase.ID)),
|
||||||
|
e164 = cursor.requireString(RecipientDatabase.PHONE),
|
||||||
|
sid = ServiceId.parseOrNull(cursor.requireString(RecipientDatabase.SERVICE_ID)),
|
||||||
|
pni = PNI.parseOrNull(cursor.requireString(RecipientDatabase.PNI_COLUMN))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data class IdRecord(
|
||||||
|
val id: RecipientId,
|
||||||
|
val e164: String?,
|
||||||
|
val sid: ServiceId?,
|
||||||
|
val pni: PNI?,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,810 @@
|
|||||||
|
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.IllegalArgumentException
|
||||||
|
import java.lang.IllegalStateException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class RecipientDatabaseTest_processPnpTupleToChangeSet {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
@JvmField
|
||||||
|
val databaseRule = SignalDatabaseRule(deleteAllThreadsOnEachRun = false)
|
||||||
|
|
||||||
|
private lateinit var db: RecipientDatabase
|
||||||
|
|
||||||
|
@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 = IllegalArgumentException::class)
|
||||||
|
fun noMatch_pniOnly() {
|
||||||
|
db.processPnpTupleToChangeSet(null, PNI_A, null, pniVerified = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::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.Update(
|
||||||
|
recipientId = result.secondId,
|
||||||
|
e164 = E164_A,
|
||||||
|
pni = PNI_A,
|
||||||
|
aci = ACI_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(
|
||||||
|
RecipientDatabase.TABLE_NAME,
|
||||||
|
null,
|
||||||
|
contentValuesOf(
|
||||||
|
RecipientDatabase.PHONE to e164,
|
||||||
|
RecipientDatabase.SERVICE_ID to (aci ?: pni)?.toString(),
|
||||||
|
RecipientDatabase.PNI_COLUMN to pni?.toString(),
|
||||||
|
RecipientDatabase.REGISTERED to RecipientDatabase.RegisteredState.REGISTERED.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return RecipientId.from(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertMockSessionFor(account: ServiceId, address: ServiceId) {
|
||||||
|
SignalDatabase.rawDatabase.insert(
|
||||||
|
SessionDatabase.TABLE_NAME, null,
|
||||||
|
contentValuesOf(
|
||||||
|
SessionDatabase.ACCOUNT_ID to account.toString(),
|
||||||
|
SessionDatabase.ADDRESS to address.toString(),
|
||||||
|
SessionDatabase.DEVICE to 1,
|
||||||
|
SessionDatabase.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 [RecipientDatabase.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,4 +122,62 @@ class SQLiteDatabaseTest {
|
|||||||
assertTrue(hasRun1.get())
|
assertTrue(hasRun1.get())
|
||||||
assertFalse(hasRun2.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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.hamcrest.MatcherAssert.assertThat
|
||||||
|
import org.hamcrest.Matchers.`is`
|
||||||
|
import org.hamcrest.Matchers.notNullValue
|
||||||
|
import org.hamcrest.Matchers.nullValue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.signal.core.util.Hex
|
||||||
|
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.DecryptedGroupV2Context
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.addMember
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.addRequestingMember
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.deleteRequestingMember
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.groupChange
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.groupContext
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
import org.thoughtcrime.securesms.sms.IncomingGroupUpdateMessage
|
||||||
|
import org.thoughtcrime.securesms.sms.IncomingTextMessage
|
||||||
|
import org.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
|
||||||
|
|
||||||
|
@Suppress("ClassName", "TestFunctionName")
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SmsDatabaseTest_collapseJoinRequestEventsIfPossible {
|
||||||
|
|
||||||
|
private lateinit var recipients: RecipientDatabase
|
||||||
|
private lateinit var sms: SmsDatabase
|
||||||
|
|
||||||
|
private val localAci = ACI.from(UUID.randomUUID())
|
||||||
|
private val localPni = PNI.from(UUID.randomUUID())
|
||||||
|
|
||||||
|
private var wallClock: Long = 1000
|
||||||
|
|
||||||
|
private lateinit var alice: RecipientId
|
||||||
|
private lateinit var bob: RecipientId
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
recipients = SignalDatabase.recipients
|
||||||
|
sms = SignalDatabase.sms
|
||||||
|
|
||||||
|
SignalStore.account().setAci(localAci)
|
||||||
|
SignalStore.account().setPni(localPni)
|
||||||
|
|
||||||
|
alice = recipients.getOrInsertFromServiceId(aliceServiceId)
|
||||||
|
bob = recipients.getOrInsertFromServiceId(bobServiceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do nothing if no previous messages.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun noPreviousMessage() {
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
1,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do nothing if previous message is text.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousTextMesssage() {
|
||||||
|
val threadId = sms.insertMessageInbox(smsMessage(sender = alice, body = "What up")).get().threadId
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do nothing if previous is unrelated group change.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousUnrelatedGroupChange() {
|
||||||
|
val threadId = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
addMember(bobServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get().threadId
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do nothing if previous join request is from a different recipient.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousJoinRequestFromADifferentRecipient() {
|
||||||
|
val threadId = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = bob,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = bobServiceId) {
|
||||||
|
deleteRequestingMember(bobServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get().threadId
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is null when not collapsing", result.orElse(null), nullValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse if previous is join request from same.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousJoinRequestCollapse() {
|
||||||
|
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
addRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get()
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
latestMessage.threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
|
||||||
|
assertThat("result message id should be same as latest message", result.get().messageId, `is`(latestMessage.messageId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse if previous is join request from same, and leave second previous alone if text.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousJoinThenTextCollapse() {
|
||||||
|
val secondLatestMessage = sms.insertMessageInbox(smsMessage(sender = alice, body = "What up")).get()
|
||||||
|
|
||||||
|
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
addRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get()
|
||||||
|
|
||||||
|
assert(secondLatestMessage.threadId == latestMessage.threadId)
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
latestMessage.threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
|
||||||
|
assertThat("result message id should be same as latest message", result.get().messageId, `is`(latestMessage.messageId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse "twice" is previous is a join request and second previous is already collapsed join/delete from the same recipient.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun previousCollapseAndJoinRequestDoubleCollapse() {
|
||||||
|
val secondLatestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
addRequestingMember(aliceServiceId)
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get()
|
||||||
|
|
||||||
|
val latestMessage: MessageDatabase.InsertResult = sms.insertMessageInbox(
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
addRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
).get()
|
||||||
|
|
||||||
|
assert(secondLatestMessage.threadId == latestMessage.threadId)
|
||||||
|
|
||||||
|
val result = sms.collapseJoinRequestEventsIfPossible(
|
||||||
|
latestMessage.threadId,
|
||||||
|
groupUpdateMessage(
|
||||||
|
sender = alice,
|
||||||
|
groupContext = groupContext(masterKey = masterKey) {
|
||||||
|
change = groupChange(editor = aliceServiceId) {
|
||||||
|
deleteRequestingMember(aliceServiceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertThat("result is not null when collapsing", result.orElse(null), notNullValue())
|
||||||
|
assertThat("result message id should be same as second latest message", result.get().messageId, `is`(secondLatestMessage.messageId))
|
||||||
|
assertThat("latest message should be deleted", sms.getMessageRecordOrNull(latestMessage.messageId), nullValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smsMessage(sender: RecipientId, body: String? = ""): IncomingTextMessage {
|
||||||
|
wallClock++
|
||||||
|
return IncomingTextMessage(sender, 1, wallClock, wallClock, wallClock, body, Optional.of(groupId), 0, true, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun groupUpdateMessage(sender: RecipientId, groupContext: DecryptedGroupV2Context): IncomingGroupUpdateMessage {
|
||||||
|
return IncomingGroupUpdateMessage(smsMessage(sender, null), groupContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val aliceServiceId: ServiceId = ACI.from(UUID.fromString("3436efbe-5a76-47fa-a98a-7e72c948a82e"))
|
||||||
|
private val bobServiceId: ServiceId = ACI.from(UUID.fromString("8de7f691-0b60-4a68-9cd9-ed2f8453f9ed"))
|
||||||
|
|
||||||
|
private val masterKey = GroupMasterKey(Hex.fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"))
|
||||||
|
private val groupId = GroupId.v2(masterKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import junit.framework.TestCase.assertNull
|
||||||
|
import org.hamcrest.MatcherAssert.assertThat
|
||||||
|
import org.hamcrest.Matchers.containsInAnyOrder
|
||||||
|
import org.hamcrest.Matchers.hasSize
|
||||||
|
import org.hamcrest.Matchers.`is`
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||||
|
import org.thoughtcrime.securesms.database.model.StoryType
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
import org.whispersystems.signalservice.api.push.DistributionId
|
||||||
|
import org.whispersystems.signalservice.api.push.ServiceId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class StorySendsDatabaseTest {
|
||||||
|
|
||||||
|
private val distributionId1 = DistributionId.from(UUID.randomUUID())
|
||||||
|
private val distributionId2 = DistributionId.from(UUID.randomUUID())
|
||||||
|
private val distributionId3 = DistributionId.from(UUID.randomUUID())
|
||||||
|
|
||||||
|
private lateinit var distributionList1: DistributionListId
|
||||||
|
private lateinit var distributionList2: DistributionListId
|
||||||
|
private lateinit var distributionList3: DistributionListId
|
||||||
|
|
||||||
|
private lateinit var distributionListRecipient1: Recipient
|
||||||
|
private lateinit var distributionListRecipient2: Recipient
|
||||||
|
private lateinit var distributionListRecipient3: Recipient
|
||||||
|
|
||||||
|
private lateinit var recipients1to10: List<RecipientId>
|
||||||
|
private lateinit var recipients11to20: List<RecipientId>
|
||||||
|
private lateinit var recipients6to15: List<RecipientId>
|
||||||
|
private lateinit var recipients6to10: List<RecipientId>
|
||||||
|
|
||||||
|
private var messageId1: Long = 0
|
||||||
|
private var messageId2: Long = 0
|
||||||
|
private var messageId3: Long = 0
|
||||||
|
|
||||||
|
private lateinit var storySends: StorySendsDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
storySends = SignalDatabase.storySends
|
||||||
|
|
||||||
|
recipients1to10 = makeRecipients(10)
|
||||||
|
recipients11to20 = makeRecipients(10)
|
||||||
|
|
||||||
|
distributionList1 = SignalDatabase.distributionLists.createList("1", emptyList(), distributionId = distributionId1)!!
|
||||||
|
distributionList2 = SignalDatabase.distributionLists.createList("2", emptyList(), distributionId = distributionId2)!!
|
||||||
|
distributionList3 = SignalDatabase.distributionLists.createList("3", emptyList(), distributionId = distributionId3)!!
|
||||||
|
|
||||||
|
distributionListRecipient1 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList1))
|
||||||
|
distributionListRecipient2 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList2))
|
||||||
|
distributionListRecipient3 = Recipient.resolved(SignalDatabase.recipients.getOrInsertFromDistributionListId(distributionList3))
|
||||||
|
|
||||||
|
messageId1 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
messageId2 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient2,
|
||||||
|
storyType = StoryType.STORY_WITH_REPLIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
messageId3 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient3,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
)
|
||||||
|
|
||||||
|
recipients6to15 = recipients1to10.takeLast(5) + recipients11to20.take(5)
|
||||||
|
recipients6to10 = recipients1to10.takeLast(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRecipientsToSendTo_noOverlap() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||||
|
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 200, true)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray()))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRecipientsToSendTo_overlap() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients6to15, 100, true, distributionId2)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(5))
|
||||||
|
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRecipientsToSendTo_overlapAll() {
|
||||||
|
val recipient1 = recipients1to10.first()
|
||||||
|
val recipient2 = recipients11to20.first()
|
||||||
|
|
||||||
|
storySends.insert(messageId1, listOf(recipient1, recipient2), 100, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, listOf(recipient1), 100, true, distributionId2)
|
||||||
|
storySends.insert(messageId3, listOf(recipient2), 100, true, distributionId3)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, false)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, true)
|
||||||
|
val recipientIdsForMessage3 = storySends.getRecipientsToSendTo(messageId3, 100, true)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(0))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(1))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(recipient1))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage3, hasSize(1))
|
||||||
|
assertThat(recipientIdsForMessage3, containsInAnyOrder(recipient2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRecipientsToSendTo_overlapWithEarlierMessage() {
|
||||||
|
storySends.insert(messageId1, recipients6to15, 100, true, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 100, false, distributionId2)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRecipientsToSendTo(messageId1, 100, true)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRecipientsToSendTo(messageId2, 100, false)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(5))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRemoteDeleteRecipients_noOverlap() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 100, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||||
|
storySends.insert(messageId3, recipients1to10, 300, false, distributionId3)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 100)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.toTypedArray()))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients11to20.toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRemoteDeleteRecipients_overlapNoPreviousDeletes() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val recipientIdsForMessage1 = storySends.getRemoteDeleteRecipients(messageId1, 200)
|
||||||
|
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage1, hasSize(5))
|
||||||
|
assertThat(recipientIdsForMessage1, containsInAnyOrder(*recipients1to10.take(5).toTypedArray()))
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(5))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.takeLast(5).toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getRemoteDeleteRecipients_overlapWithPreviousDeletes() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||||
|
|
||||||
|
storySends.insert(messageId2, recipients6to15, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val recipientIdsForMessage2 = storySends.getRemoteDeleteRecipients(messageId2, 200)
|
||||||
|
|
||||||
|
assertThat(recipientIdsForMessage2, hasSize(10))
|
||||||
|
assertThat(recipientIdsForMessage2, containsInAnyOrder(*recipients6to15.toTypedArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canReply_storyWithReplies() {
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val canReply = storySends.canReply(recipients1to10[0], 200)
|
||||||
|
|
||||||
|
assertThat(canReply, `is`(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canReply_storyWithoutReplies() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
|
||||||
|
val canReply = storySends.canReply(recipients1to10[0], 200)
|
||||||
|
|
||||||
|
assertThat(canReply, `is`(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun canReply_storyWithAndWithoutRepliesOverlap() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients6to10, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val message1OnlyRecipientCanReply = storySends.canReply(recipients1to10[0], 200)
|
||||||
|
val message2RecipientCanReply = storySends.canReply(recipients6to10[0], 200)
|
||||||
|
|
||||||
|
assertThat(message1OnlyRecipientCanReply, `is`(false))
|
||||||
|
assertThat(message2RecipientCanReply, `is`(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenASingleStory_whenIGetFullSentStorySyncManifest_thenIExpectNotNull() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||||
|
|
||||||
|
assertNotNull(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNull() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, false, distributionId2)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)
|
||||||
|
|
||||||
|
assertNull(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectOneManifestPerRecipient() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||||
|
|
||||||
|
assertEquals(recipients1to10, manifest.entries.map { it.recipientId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectTwoListsPerRecipient() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||||
|
|
||||||
|
manifest.entries.forEach { entry ->
|
||||||
|
assertEquals(listOf(distributionId1, distributionId2), entry.distributionLists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStories_whenIGetFullSentStorySyncManifestForStory1_thenIExpectAllRecipientsCanReply() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId1, 200)!!
|
||||||
|
|
||||||
|
manifest.entries.forEach { entry ->
|
||||||
|
assertTrue(entry.allowedToReply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetFullSentStorySyncManifestForStory2_thenIExpectNonNullResult() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||||
|
|
||||||
|
val manifest = storySends.getFullSentStorySyncManifest(messageId2, 200)!!
|
||||||
|
|
||||||
|
assertNotNull(manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetRecipientIdsForManifestUpdate_thenIExpectOnlyRecipientsWithStory2() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId1, recipients11to20, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||||
|
|
||||||
|
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||||
|
|
||||||
|
assertEquals(recipients1to10.toHashSet(), recipientIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStoriesAndOneIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectOnlyRecipientsThatHadStory1() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
storySends.insert(messageId2, recipients11to20, 200, true, distributionId2)
|
||||||
|
SignalDatabase.mms.markAsRemoteDelete(messageId1)
|
||||||
|
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||||
|
|
||||||
|
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||||
|
|
||||||
|
val manifestRecipients = results.entries.map { it.recipientId }
|
||||||
|
assertEquals(recipients1to10, manifestRecipients)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenTwoStoriesAndTheOneThatAllowedRepliesIsRemoteDeleted_whenIGetPartialSentStorySyncManifest_thenIExpectAllowRepliesToBeTrue() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
storySends.insert(messageId2, recipients1to10, 200, true, distributionId2)
|
||||||
|
SignalDatabase.mms.markAsRemoteDelete(messageId2)
|
||||||
|
val recipientIds = storySends.getRecipientIdsForManifestUpdate(200, messageId1)
|
||||||
|
|
||||||
|
val results = storySends.getSentStorySyncManifestForUpdate(200, recipientIds)
|
||||||
|
|
||||||
|
assertTrue(results.entries.all { it.allowedToReply })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenEmptyManifest_whenIApplyRemoteManifest_thenNothingChanges() {
|
||||||
|
storySends.insert(messageId1, recipients1to10, 200, false, distributionId1)
|
||||||
|
val expected = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||||
|
val emptyManifest = SentStorySyncManifest(emptyList())
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(emptyManifest, 200)
|
||||||
|
val result = storySends.getFullSentStorySyncManifest(messageId1, 200)
|
||||||
|
|
||||||
|
assertEquals(expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenAnIdenticalManifest_whenIApplyRemoteManifest_thenNothingChanges() {
|
||||||
|
val messageId4 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||||
|
val expected = storySends.getFullSentStorySyncManifest(messageId4, 200)
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(expected!!, 200)
|
||||||
|
val result = storySends.getFullSentStorySyncManifest(messageId4, 200)
|
||||||
|
|
||||||
|
assertEquals(expected, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectMessageToBeMarkedRemoteDeleted() {
|
||||||
|
val messageId4 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
val messageId5 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient2,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||||
|
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
|
||||||
|
|
||||||
|
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(remote, 200)
|
||||||
|
|
||||||
|
assertTrue(SignalDatabase.mms.getMessageRecord(messageId5).isRemoteDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenAManifest_whenIApplyRemoteManifestWithoutOneList_thenIExpectSharedMessageToNotBeMarkedRemoteDeleted() {
|
||||||
|
val messageId4 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
val messageId5 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient2,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 200
|
||||||
|
)
|
||||||
|
|
||||||
|
storySends.insert(messageId4, recipients1to10, 200, false, distributionId1)
|
||||||
|
val remote = storySends.getFullSentStorySyncManifest(messageId4, 200)!!
|
||||||
|
|
||||||
|
storySends.insert(messageId5, recipients1to10, 200, false, distributionId2)
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(remote, 200)
|
||||||
|
|
||||||
|
assertFalse(SignalDatabase.mms.getMessageRecord(messageId4).isRemoteDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenNoLocalEntries_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatch() {
|
||||||
|
val messageId4 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
val remote = SentStorySyncManifest(
|
||||||
|
recipients1to10.map {
|
||||||
|
SentStorySyncManifest.Entry(
|
||||||
|
recipientId = it,
|
||||||
|
allowedToReply = true,
|
||||||
|
distributionLists = listOf(distributionId1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(remote, 2000)
|
||||||
|
|
||||||
|
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
|
||||||
|
assertEquals(remote, local)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun givenNonStoryMessageAtSentTimestamp_whenIApplyRemoteManifest_thenIExpectLocalManifestToMatchAndNoCrashes() {
|
||||||
|
val messageId4 = MmsHelper.insert(
|
||||||
|
recipient = distributionListRecipient1,
|
||||||
|
storyType = StoryType.STORY_WITHOUT_REPLIES,
|
||||||
|
sentTimeMillis = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
MmsHelper.insert(
|
||||||
|
recipient = Recipient.resolved(recipients1to10.first()),
|
||||||
|
sentTimeMillis = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
val remote = SentStorySyncManifest(
|
||||||
|
recipients1to10.map {
|
||||||
|
SentStorySyncManifest.Entry(
|
||||||
|
recipientId = it,
|
||||||
|
allowedToReply = true,
|
||||||
|
distributionLists = listOf(distributionId1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
storySends.applySentStoryManifest(remote, 2000)
|
||||||
|
|
||||||
|
val local = storySends.getFullSentStorySyncManifest(messageId4, 2000)
|
||||||
|
assertEquals(remote, local)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeRecipients(count: Int): List<RecipientId> {
|
||||||
|
return (1..count).map {
|
||||||
|
SignalDatabase.recipients.getOrInsertFromServiceId(ServiceId.from(UUID.randomUUID()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.testing.SignalDatabaseRule
|
||||||
|
import org.whispersystems.signalservice.api.push.ServiceId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Suppress("ClassName")
|
||||||
|
class ThreadDatabaseTest_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.mms.deleteMessage(messageId)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
val pinned = SignalDatabase.threads.pinnedThreadIds
|
||||||
|
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.mms.deleteMessage(messageId)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
val unarchivedCount = SignalDatabase.threads.unarchivedConversationListCount
|
||||||
|
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.mms.deleteMessage(messageId)
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
SignalDatabase.threads.getUnarchivedConversationList(true, 0, 1).use {
|
||||||
|
it.moveToFirst()
|
||||||
|
assertEquals(threadId, CursorUtil.requireLong(it, ThreadDatabase.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4;
|
|||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.junit.runner.RunWith;
|
import org.junit.runner.RunWith;
|
||||||
import org.thoughtcrime.securesms.util.Hex;
|
import org.signal.core.util.Hex;
|
||||||
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
import org.whispersystems.signalservice.api.kbs.HashedPin;
|
||||||
import org.whispersystems.signalservice.api.kbs.KbsData;
|
import org.whispersystems.signalservice.api.kbs.KbsData;
|
||||||
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
import org.whispersystems.signalservice.api.kbs.MasterKey;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 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.IdentityDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
|
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.internal.push.VerifyAccountResponse
|
||||||
|
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 : 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
registrationRepository.registerAccountWithoutRegistrationLock(
|
||||||
|
RegistrationData(
|
||||||
|
code = "123123",
|
||||||
|
e164 = "+15554045550101",
|
||||||
|
password = Util.getSecret(18),
|
||||||
|
registrationId = registrationRepository.registrationId,
|
||||||
|
profileKey = registrationRepository.getProfileKey("+15554045550101"),
|
||||||
|
fcmToken = null
|
||||||
|
),
|
||||||
|
VerifyAccountResponse(UUID.randomUUID().toString(), UUID.randomUUID().toString(), false)
|
||||||
|
).blockingGet()
|
||||||
|
|
||||||
|
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>()
|
||||||
|
|
||||||
|
for (i in 0..4) {
|
||||||
|
val aci = ACI.from(UUID.randomUUID())
|
||||||
|
val recipientId = RecipientId.from(aci, "+1555555101$i")
|
||||||
|
SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i"))
|
||||||
|
SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew())
|
||||||
|
SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true))
|
||||||
|
SignalDatabase.recipients.setProfileSharing(recipientId, true)
|
||||||
|
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: IdentityDatabase.VerifiedStatus) {
|
||||||
|
ApplicationDependencies.getProtocolStore().aci().identities().setVerified(recipient.id, getIdentity(recipient), IdentityDatabase.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.mms.deleteAllThreads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,8 @@
|
|||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
|
||||||
|
|
||||||
<application android:name=".ApplicationContext"
|
<application android:name=".ApplicationContext"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -177,11 +179,12 @@
|
|||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity android:name=".sharing.ShareActivity"
|
<activity android:name=".sharing.v2.ShareActivity"
|
||||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="stateHidden"
|
||||||
|
android:exported="true"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
@@ -221,7 +224,7 @@
|
|||||||
<data android:scheme="sgnl"
|
<data android:scheme="sgnl"
|
||||||
android:host="addstickers" />
|
android:host="addstickers" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -255,7 +258,7 @@
|
|||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:theme="@style/Signal.Transparent">
|
android:theme="@style/Signal.Transparent">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -272,7 +275,7 @@
|
|||||||
android:host="signal.group"/>
|
android:host="signal.group"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -282,7 +285,7 @@
|
|||||||
android:host="signal.tube" />
|
android:host="signal.tube" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
@@ -386,6 +389,32 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".stories.my.MyStoriesActivity"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".stories.settings.StorySettingsActivity"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden|adjustResize" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".stories.viewer.StoryViewerActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
|
android:theme="@style/TextSecure.DarkNoActionBar.StoryViewer"
|
||||||
|
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"
|
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
@@ -396,6 +425,11 @@
|
|||||||
android:windowSoftInputMode="stateAlwaysHidden">
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".badges.gifts.flow.GiftFlowActivity"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
|
</activity>
|
||||||
|
|
||||||
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
<activity android:name=".wallpaper.ChatWallpaperActivity"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
@@ -437,6 +471,7 @@
|
|||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".DeviceActivity"
|
<activity android:name=".DeviceActivity"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
android:label="@string/AndroidManifest__linked_devices"
|
android:label="@string/AndroidManifest__linked_devices"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
@@ -514,6 +549,7 @@
|
|||||||
|
|
||||||
<activity android:name=".blocked.BlockedUsersActivity"
|
<activity android:name=".blocked.BlockedUsersActivity"
|
||||||
android:theme="@style/TextSecure.LightTheme"
|
android:theme="@style/TextSecure.LightTheme"
|
||||||
|
android:windowSoftInputMode="stateHidden"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".scribbles.ImageEditorStickerSelectActivity"
|
<activity android:name=".scribbles.ImageEditorStickerSelectActivity"
|
||||||
@@ -624,10 +660,17 @@
|
|||||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService"/>
|
<service android:enabled="true" android:name=".service.webrtc.WebRtcCallService" android:foregroundServiceType="camera|microphone"/>
|
||||||
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
<service android:enabled="true" android:name=".service.ApplicationMigrationService"/>
|
||||||
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
<service android:enabled="true" android:exported="false" android:name=".service.KeyCachingService"/>
|
||||||
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
<service android:enabled="true" android:name=".messages.IncomingMessageObserver$ForegroundService"/>
|
||||||
|
<service android:name=".service.webrtc.AndroidCallConnectionService"
|
||||||
|
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.telecom.ConnectionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
<service android:name=".components.voice.VoiceNotePlaybackService">
|
<service android:name=".components.voice.VoiceNotePlaybackService">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -671,7 +714,9 @@
|
|||||||
|
|
||||||
<service android:name=".service.GenericForegroundService"/>
|
<service android:name=".service.GenericForegroundService"/>
|
||||||
|
|
||||||
<service android:name=".gcm.FcmFetchService" />
|
<service android:name=".gcm.FcmFetchBackgroundService" />
|
||||||
|
|
||||||
|
<service android:name=".gcm.FcmFetchForegroundService" />
|
||||||
|
|
||||||
<service android:name=".gcm.FcmReceiveService">
|
<service android:name=".gcm.FcmReceiveService">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -730,6 +775,8 @@
|
|||||||
|
|
||||||
<receiver android:name=".service.ExpirationListener" />
|
<receiver android:name=".service.ExpirationListener" />
|
||||||
|
|
||||||
|
<receiver android:name=".service.ExpiringStoriesManager$ExpireStoriesAlarm" />
|
||||||
|
|
||||||
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
|
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
|
||||||
|
|
||||||
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
|
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
package com.google.android.material.bottomsheet
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually adjust the nested scrolling child for a given [BottomSheetBehavior].
|
||||||
|
*/
|
||||||
|
object BottomSheetBehaviorHack {
|
||||||
|
fun setNestedScrollingChild(behavior: BottomSheetBehavior<FrameLayout>, view: View) {
|
||||||
|
behavior.nestedScrollingChildRef = WeakReference(view)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
import org.whispersystems.signalservice.api.account.AccountAttributes;
|
||||||
|
|
||||||
public final class AppCapabilities {
|
public final class AppCapabilities {
|
||||||
@@ -19,6 +20,6 @@ public final class AppCapabilities {
|
|||||||
* asking if the user has set a Signal PIN or not.
|
* asking if the user has set a Signal PIN or not.
|
||||||
*/
|
*/
|
||||||
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
public static AccountAttributes.Capabilities getCapabilities(boolean storageCapable) {
|
||||||
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER);
|
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, CHANGE_NUMBER, FeatureFlags.stories(), FeatureFlags.giftBadges());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,30 +35,32 @@ import org.signal.core.util.logging.AndroidLogger;
|
|||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.core.util.tracing.Tracer;
|
import org.signal.core.util.tracing.Tracer;
|
||||||
import org.signal.glide.SignalGlideCodecs;
|
import org.signal.glide.SignalGlideCodecs;
|
||||||
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
import org.signal.libsignal.protocol.logging.SignalProtocolLoggerProvider;
|
||||||
import org.thoughtcrime.securesms.jobs.RetrieveReleaseChannelJob;
|
|
||||||
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
|
||||||
import org.signal.ringrtc.CallManager;
|
import org.signal.ringrtc.CallManager;
|
||||||
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||||
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||||
import org.thoughtcrime.securesms.database.LogDatabase;
|
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencyProvider;
|
||||||
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
import org.thoughtcrime.securesms.emoji.EmojiSource;
|
||||||
|
import org.thoughtcrime.securesms.emoji.JumboEmoji;
|
||||||
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
import org.thoughtcrime.securesms.gcm.FcmJobService;
|
||||||
|
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
|
||||||
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
import org.thoughtcrime.securesms.jobs.CreateSignedPreKeyJob;
|
||||||
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
|
||||||
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
import org.thoughtcrime.securesms.jobs.EmojiSearchIndexDownloadJob;
|
||||||
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
import org.thoughtcrime.securesms.jobs.FcmRefreshJob;
|
||||||
import org.thoughtcrime.securesms.jobs.GroupV1MigrationJob;
|
import org.thoughtcrime.securesms.jobs.FontDownloaderJob;
|
||||||
|
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||||
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
import org.thoughtcrime.securesms.jobs.ProfileUploadJob;
|
||||||
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
import org.thoughtcrime.securesms.jobs.PushNotificationReceiveJob;
|
||||||
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
import org.thoughtcrime.securesms.jobs.RefreshPreKeysJob;
|
||||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
|
||||||
|
import org.thoughtcrime.securesms.jobs.RetrieveRemoteAnnouncementsJob;
|
||||||
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
import org.thoughtcrime.securesms.jobs.SubscriptionKeepAliveJob;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
import org.thoughtcrime.securesms.logging.CustomSignalProtocolLogger;
|
||||||
@@ -66,6 +68,7 @@ import org.thoughtcrime.securesms.logging.PersistentLogger;
|
|||||||
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
import org.thoughtcrime.securesms.messageprocessingalarm.MessageProcessReceiver;
|
||||||
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
import org.thoughtcrime.securesms.migrations.ApplicationMigrations;
|
||||||
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
import org.thoughtcrime.securesms.mms.SignalGlideComponents;
|
||||||
|
import org.thoughtcrime.securesms.mms.SignalGlideModule;
|
||||||
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||||
@@ -78,6 +81,7 @@ import org.thoughtcrime.securesms.service.LocalBackupListener;
|
|||||||
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
|
||||||
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
|
||||||
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
import org.thoughtcrime.securesms.service.UpdateApkRefreshListener;
|
||||||
|
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||||
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||||
import org.thoughtcrime.securesms.util.AppStartup;
|
import org.thoughtcrime.securesms.util.AppStartup;
|
||||||
@@ -89,9 +93,7 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
|||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.VersionTracker;
|
import org.thoughtcrime.securesms.util.VersionTracker;
|
||||||
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
import org.thoughtcrime.securesms.util.dynamiclanguage.DynamicLanguageContextWrapper;
|
||||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.SocketException;
|
import java.net.SocketException;
|
||||||
import java.net.SocketTimeoutException;
|
import java.net.SocketTimeoutException;
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
@@ -188,6 +190,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
.addNonBlocking(EmojiSource::refresh)
|
.addNonBlocking(EmojiSource::refresh)
|
||||||
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
||||||
.addNonBlocking(this::ensureProfileUploaded)
|
.addNonBlocking(this::ensureProfileUploaded)
|
||||||
|
.addNonBlocking(() -> ApplicationDependencies.getExpireStoriesManager().scheduleIfNecessary())
|
||||||
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
.addPostRender(() -> RateLimitUtil.retryAllRateLimitedMessages(this))
|
||||||
.addPostRender(this::initializeExpiringMessageManager)
|
.addPostRender(this::initializeExpiringMessageManager)
|
||||||
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
.addPostRender(() -> SignalStore.settings().setDefaultSms(Util.isDefaultSmsProvider(this)))
|
||||||
@@ -195,7 +198,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||||
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||||
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
.addPostRender(() -> JumboEmoji.updateCurrentVersion(this))
|
||||||
.addPostRender(RetrieveReleaseChannelJob::enqueue)
|
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
|
||||||
|
.addPostRender(() -> AndroidTelecomUtil.registerPhoneAccount())
|
||||||
|
.addPostRender(() -> ApplicationDependencies.getJobManager().add(new FontDownloaderJob()))
|
||||||
|
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
|
||||||
|
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||||
@@ -211,7 +218,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
ApplicationDependencies.getFrameRateTracker().start();
|
ApplicationDependencies.getFrameRateTracker().start();
|
||||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||||
ApplicationDependencies.getDeadlockDetector().start();
|
ApplicationDependencies.getDeadlockDetector().start();
|
||||||
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary();
|
SubscriptionKeepAliveJob.enqueueAndTrackTimeIfNecessary();
|
||||||
|
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
FeatureFlags.refreshIfNecessary();
|
FeatureFlags.refreshIfNecessary();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import android.widget.ImageView;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.core.app.ActivityOptionsCompat;
|
import androidx.core.app.ActivityOptionsCompat;
|
||||||
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
|
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
|
||||||
@@ -58,6 +59,12 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
|||||||
return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
|
return ActivityOptionsCompat.makeSceneTransitionAnimation(activity, from, "avatar").toBundle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void attachBaseContext(@NonNull Context newBase) {
|
||||||
|
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||||
|
super.attachBaseContext(newBase);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||||
super.onCreate(savedInstanceState, ready);
|
super.onCreate(savedInstanceState, ready);
|
||||||
@@ -132,7 +139,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
|||||||
|
|
||||||
findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
|
findViewById(android.R.id.content).setOnClickListener(v -> fullscreenHelper.toggleUiVisibility());
|
||||||
|
|
||||||
fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
|
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), toolbar);
|
||||||
|
|
||||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
|
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.toolbar_layout));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
|
|||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
|
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
|
||||||
@@ -46,7 +46,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
boolean hasWallpaper,
|
boolean hasWallpaper,
|
||||||
boolean isMessageRequestAccepted,
|
boolean isMessageRequestAccepted,
|
||||||
boolean canPlayInline,
|
boolean canPlayInline,
|
||||||
@NonNull Colorizer colorizer);
|
@NonNull Colorizer colorizer,
|
||||||
|
boolean isCondensedMode);
|
||||||
|
|
||||||
@NonNull ConversationMessage getConversationMessage();
|
@NonNull ConversationMessage getConversationMessage();
|
||||||
|
|
||||||
@@ -60,9 +61,14 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
// Intentionally Blank.
|
// Intentionally Blank.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default void updateSelectedState() {
|
||||||
|
// Intentionally Blank.
|
||||||
|
}
|
||||||
|
|
||||||
interface EventListener {
|
interface EventListener {
|
||||||
void onQuoteClicked(MmsMessageRecord messageRecord);
|
void onQuoteClicked(MmsMessageRecord messageRecord);
|
||||||
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
||||||
|
void onQuotedIndicatorClicked(@NonNull MessageRecord messageRecord);
|
||||||
void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms);
|
void onMoreTextClicked(@NonNull RecipientId conversationRecipientId, long messageId, boolean isMms);
|
||||||
void onStickerClicked(@NonNull StickerLocator stickerLocator);
|
void onStickerClicked(@NonNull StickerLocator stickerLocator);
|
||||||
void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
|
void onViewOnceMessageClicked(@NonNull MmsMessageRecord messageRecord);
|
||||||
@@ -94,8 +100,13 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
||||||
void onCallToAction(@NonNull String action);
|
void onCallToAction(@NonNull String action);
|
||||||
void onDonateClicked();
|
void onDonateClicked();
|
||||||
|
void onBlockJoinRequest(@NonNull Recipient recipient);
|
||||||
|
void onRecipientNameClicked(@NonNull RecipientId target);
|
||||||
|
|
||||||
/** @return true if handled, false if you want to let the normal url handling continue */
|
/** @return true if handled, false if you want to let the normal url handling continue */
|
||||||
boolean onUrlClicked(@NonNull String url);
|
boolean onUrlClicked(@NonNull String url);
|
||||||
|
|
||||||
|
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
|
||||||
|
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
|
||||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
import org.thoughtcrime.securesms.conversationlist.model.ConversationSet;
|
||||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||||
@@ -12,7 +12,8 @@ import java.util.Set;
|
|||||||
|
|
||||||
public interface BindableConversationListItem extends Unbindable {
|
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 GlideRequests glideRequests, @NonNull Locale locale,
|
||||||
@NonNull Set<Long> typingThreads,
|
@NonNull Set<Long> typingThreads,
|
||||||
@NonNull ConversationSet selectedConversations);
|
@NonNull ConversationSet selectedConversations);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
|||||||
|
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
import org.signal.core.util.concurrent.SimpleTask;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This should be used whenever we want to prompt the user to block/unblock a recipient.
|
* This should be used whenever we want to prompt the user to block/unblock a recipient.
|
||||||
@@ -83,7 +83,8 @@ public final class BlockUnblockDialog {
|
|||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
builder.setNegativeButton(android.R.string.cancel, null);
|
||||||
} else {
|
} else {
|
||||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
|
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_s, recipient.getDisplayName(context)));
|
||||||
builder.setMessage(R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages);
|
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_call_you_or_send_you_messages
|
||||||
|
: R.string.BlockUnblockDialog_blocked_people_wont_be_able_to_send_you_messages);
|
||||||
|
|
||||||
if (onBlockAndReportSpam != null) {
|
if (onBlockAndReportSpam != null) {
|
||||||
builder.setNeutralButton(android.R.string.cancel, null);
|
builder.setNeutralButton(android.R.string.cancel, null);
|
||||||
@@ -128,7 +129,8 @@ public final class BlockUnblockDialog {
|
|||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
builder.setNegativeButton(android.R.string.cancel, null);
|
||||||
} else {
|
} else {
|
||||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
|
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_unblock_s, recipient.getDisplayName(context)));
|
||||||
builder.setMessage(R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other);
|
builder.setMessage(recipient.isRegistered() ? R.string.BlockUnblockDialog_you_will_be_able_to_call_and_message_each_other
|
||||||
|
: R.string.BlockUnblockDialog_you_will_be_able_to_message_each_other);
|
||||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
||||||
builder.setNegativeButton(android.R.string.cancel, null);
|
builder.setNegativeButton(android.R.string.cancel, null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,22 +20,23 @@ import android.content.Context;
|
|||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.components.ContactFilterView;
|
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,12 +124,12 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||||
callback.accept(true);
|
callback.accept(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}
|
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBeginScroll() {
|
public void onBeginScroll() {
|
||||||
@@ -152,7 +153,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
@Override
|
@Override
|
||||||
protected Void doInBackground(Context... params) {
|
protected Void doInBackground(Context... params) {
|
||||||
try {
|
try {
|
||||||
DirectoryHelper.refreshDirectory(params[0], true);
|
ContactDiscovery.refreshAll(params[0], true);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ package org.thoughtcrime.securesms;
|
|||||||
|
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
import android.animation.LayoutTransition;
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
import android.graphics.Rect;
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
@@ -31,7 +31,6 @@ import android.view.View;
|
|||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.HorizontalScrollView;
|
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
@@ -42,6 +41,7 @@ import androidx.appcompat.app.AlertDialog;
|
|||||||
import androidx.constraintlayout.widget.ConstraintLayout;
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
import androidx.constraintlayout.widget.ConstraintSet;
|
import androidx.constraintlayout.widget.ConstraintSet;
|
||||||
import androidx.fragment.app.FragmentActivity;
|
import androidx.fragment.app.FragmentActivity;
|
||||||
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
import androidx.loader.app.LoaderManager;
|
import androidx.loader.app.LoaderManager;
|
||||||
import androidx.loader.content.Loader;
|
import androidx.loader.content.Loader;
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator;
|
import androidx.recyclerview.widget.DefaultItemAnimator;
|
||||||
@@ -53,58 +53,64 @@ import androidx.transition.TransitionManager;
|
|||||||
|
|
||||||
import com.annimon.stream.Collectors;
|
import com.annimon.stream.Collectors;
|
||||||
import com.annimon.stream.Stream;
|
import com.annimon.stream.Stream;
|
||||||
import com.google.android.material.chip.ChipGroup;
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||||
|
|
||||||
|
import org.signal.core.util.concurrent.SimpleTask;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
import org.thoughtcrime.securesms.components.RecyclerViewFastScroller;
|
||||||
import org.thoughtcrime.securesms.components.recyclerview.ToolbarShadowAnimationHelper;
|
|
||||||
import org.thoughtcrime.securesms.contacts.AbstractContactsCursorLoader;
|
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.ContactSelectionListAdapter;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
import org.thoughtcrime.securesms.contacts.ContactSelectionListItem;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||||
|
import org.thoughtcrime.securesms.contacts.HeaderAction;
|
||||||
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
import org.thoughtcrime.securesms.contacts.LetterHeaderDecoration;
|
||||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
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.SelectionLimits;
|
||||||
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
import org.thoughtcrime.securesms.groups.ui.GroupLimitDialog;
|
||||||
import org.thoughtcrime.securesms.mms.GlideApp;
|
import org.thoughtcrime.securesms.mms.GlideApp;
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests;
|
import org.thoughtcrime.securesms.mms.GlideRequests;
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
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.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.UsernameUtil;
|
import org.thoughtcrime.securesms.util.UsernameUtil;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
import org.thoughtcrime.securesms.util.adapter.FixedViewsAdapter;
|
||||||
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
import org.thoughtcrime.securesms.util.adapter.RecyclerViewConcatenateAdapterStickyHeader;
|
||||||
import org.thoughtcrime.securesms.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 org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.function.Consumer;
|
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.
|
* Fragment for selecting a one or more contacts from a list.
|
||||||
*
|
*
|
||||||
* @author Moxie Marlinspike
|
* @author Moxie Marlinspike
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public final class ContactSelectionListFragment extends LoggingFragment
|
public final class ContactSelectionListFragment extends LoggingFragment
|
||||||
implements LoaderManager.LoaderCallbacks<Cursor>
|
implements LoaderManager.LoaderCallbacks<Cursor>
|
||||||
{
|
{
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static final String TAG = Log.tag(ContactSelectionListFragment.class);
|
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;
|
private static final int CHIP_GROUP_REVEAL_DURATION_MS = 150;
|
||||||
|
|
||||||
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
public static final int NO_LIMIT = Integer.MAX_VALUE;
|
||||||
@@ -132,24 +138,26 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
private RecyclerView recyclerView;
|
private RecyclerView recyclerView;
|
||||||
private RecyclerViewFastScroller fastScroller;
|
private RecyclerViewFastScroller fastScroller;
|
||||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||||
private ChipGroup chipGroup;
|
private RecyclerView chipRecycler;
|
||||||
private HorizontalScrollView chipGroupScrollContainer;
|
|
||||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||||
private View shadowView;
|
private MappingAdapter contactChipAdapter;
|
||||||
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
private ContactChipViewModel contactChipViewModel;
|
||||||
|
private LifecycleDisposable lifecycleDisposable;
|
||||||
|
|
||||||
|
private HeaderActionProvider headerActionProvider;
|
||||||
|
private TextView headerActionView;
|
||||||
|
|
||||||
@Nullable private FixedViewsAdapter headerAdapter;
|
@Nullable private FixedViewsAdapter headerAdapter;
|
||||||
@Nullable private FixedViewsAdapter footerAdapter;
|
@Nullable private FixedViewsAdapter footerAdapter;
|
||||||
@Nullable private ListCallback listCallback;
|
@Nullable private ListCallback listCallback;
|
||||||
@Nullable private ScrollCallback scrollCallback;
|
@Nullable private ScrollCallback scrollCallback;
|
||||||
private GlideRequests glideRequests;
|
private GlideRequests glideRequests;
|
||||||
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
private SelectionLimits selectionLimit = SelectionLimits.NO_LIMITS;
|
||||||
private Set<RecipientId> currentSelection;
|
private Set<RecipientId> currentSelection;
|
||||||
private boolean isMulti;
|
private boolean isMulti;
|
||||||
private boolean hideCount;
|
private boolean hideCount;
|
||||||
private boolean canSelectSelf;
|
private boolean canSelectSelf;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onAttach(@NonNull Context context) {
|
public void onAttach(@NonNull Context context) {
|
||||||
@@ -190,6 +198,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context instanceof HeaderActionProvider) {
|
||||||
|
headerActionProvider = (HeaderActionProvider) context;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getParentFragment() instanceof HeaderActionProvider) {
|
||||||
|
headerActionProvider = (HeaderActionProvider) getParentFragment();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -239,15 +255,13 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
showContactsButton = view.findViewById(R.id.show_contacts_button);
|
||||||
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
showContactsDescription = view.findViewById(R.id.show_contacts_description);
|
||||||
showContactsProgress = view.findViewById(R.id.progress);
|
showContactsProgress = view.findViewById(R.id.progress);
|
||||||
chipGroup = view.findViewById(R.id.chipGroup);
|
chipRecycler = view.findViewById(R.id.chipRecycler);
|
||||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
|
||||||
constraintLayout = view.findViewById(R.id.container);
|
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.setLayoutManager(new LinearLayoutManager(getActivity()));
|
|
||||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||||
@Override
|
@Override
|
||||||
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
public boolean canReuseUpdatedViewHolder(@NonNull RecyclerView.ViewHolder viewHolder) {
|
||||||
@@ -255,6 +269,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();
|
Intent intent = requireActivity().getIntent();
|
||||||
Bundle arguments = safeArguments();
|
Bundle arguments = safeArguments();
|
||||||
|
|
||||||
@@ -285,6 +311,40 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
currentSelection = getCurrentSelection();
|
currentSelection = getCurrentSelection();
|
||||||
|
|
||||||
|
final HeaderAction headerAction;
|
||||||
|
if (headerActionProvider != null) {
|
||||||
|
headerAction = headerActionProvider.getHeaderAction();
|
||||||
|
|
||||||
|
headerActionView.setEnabled(true);
|
||||||
|
headerActionView.setText(headerAction.getLabel());
|
||||||
|
headerActionView.setCompoundDrawablesRelativeWithIntrinsicBounds(headerAction.getIcon(), 0, 0, 0);
|
||||||
|
headerActionView.setOnClickListener(v -> headerAction.getAction().run());
|
||||||
|
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||||
|
|
||||||
|
private final Rect bounds = new Rect();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
|
||||||
|
if (hideLetterHeaders()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int firstPosition = layoutManager.findFirstVisibleItemPosition();
|
||||||
|
if (firstPosition == 0) {
|
||||||
|
View firstChild = recyclerView.getChildAt(0);
|
||||||
|
recyclerView.getDecoratedBoundsWithMargins(firstChild, bounds);
|
||||||
|
headerActionView.setTranslationY(bounds.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
headerActionView.setEnabled(false);
|
||||||
|
}
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +403,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
null,
|
null,
|
||||||
new ListClickListener(),
|
new ListClickListener(),
|
||||||
isMulti,
|
isMulti,
|
||||||
currentSelection);
|
currentSelection,
|
||||||
|
safeArguments().getInt(ContactSelectionArguments.CHECKBOX_RESOURCE, R.drawable.contact_selection_checkbox));
|
||||||
|
|
||||||
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
RecyclerViewConcatenateAdapterStickyHeader concatenateAdapter = new RecyclerViewConcatenateAdapterStickyHeader();
|
||||||
|
|
||||||
@@ -491,12 +552,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
fastScroller.setRecyclerView(null);
|
fastScroller.setRecyclerView(null);
|
||||||
fastScroller.setVisibility(View.GONE);
|
fastScroller.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (headerActionView.isEnabled() && !hasQueryFilter()) {
|
||||||
|
headerActionView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
headerActionView.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
public void onLoaderReset(@NonNull Loader<Cursor> loader) {
|
||||||
cursorRecyclerViewAdapter.changeCursor(null);
|
cursorRecyclerViewAdapter.changeCursor(null);
|
||||||
fastScroller.setVisibility(View.GONE);
|
fastScroller.setVisibility(View.GONE);
|
||||||
|
headerActionView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldDisplayRecents() {
|
private boolean shouldDisplayRecents() {
|
||||||
@@ -521,7 +589,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
@Override
|
@Override
|
||||||
protected Boolean doInBackground(Void... voids) {
|
protected Boolean doInBackground(Void... voids) {
|
||||||
try {
|
try {
|
||||||
DirectoryHelper.refreshDirectory(context, false);
|
ContactDiscovery.refreshAll(context, false);
|
||||||
return true;
|
return true;
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
@@ -546,11 +614,44 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}.execute();
|
}.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the caller to submit a list of recipients to be marked selected. Useful for when a screen needs to load preselected
|
||||||
|
* entries in the background before setting them in the adapter.
|
||||||
|
*
|
||||||
|
* @param contacts List of the contacts to select. This will not overwrite the current selection, but append to it.
|
||||||
|
*/
|
||||||
|
public void markSelected(@NonNull Set<ShareContact> contacts) {
|
||||||
|
if (contacts.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<SelectedContact> toMarkSelected = contacts.stream()
|
||||||
|
.map(contact -> {
|
||||||
|
if (contact.getRecipientId().isPresent()) {
|
||||||
|
return SelectedContact.forRecipientId(contact.getRecipientId().get());
|
||||||
|
} else {
|
||||||
|
return SelectedContact.forPhone(null, contact.getNumber());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(c -> !cursorRecyclerViewAdapter.isSelectedContact(c))
|
||||||
|
.collect(java.util.stream.Collectors.toSet());
|
||||||
|
|
||||||
|
if (toMarkSelected.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final SelectedContact selectedContact : toMarkSelected) {
|
||||||
|
markContactSelected(selectedContact);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount());
|
||||||
|
}
|
||||||
|
|
||||||
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
|
private class ListClickListener implements ContactSelectionListAdapter.ItemClickListener {
|
||||||
@Override
|
@Override
|
||||||
public void onItemClick(ContactSelectionListItem contact) {
|
public void onItemClick(ContactSelectionListItem contact) {
|
||||||
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orNull(), contact.getNumber())
|
SelectedContact selectedContact = contact.isUsernameType() ? SelectedContact.forUsername(contact.getRecipientId().orElse(null), contact.getNumber())
|
||||||
: SelectedContact.forPhone(contact.getRecipientId().orNull(), contact.getNumber());
|
: SelectedContact.forPhone(contact.getRecipientId().orElse(null), contact.getNumber());
|
||||||
|
|
||||||
if (!canSelectSelf && Recipient.self().getId().equals(selectedContact.getOrCreateRecipientId(requireContext()))) {
|
if (!canSelectSelf && 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();
|
Toast.makeText(requireContext(), R.string.ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group, Toast.LENGTH_SHORT).show();
|
||||||
@@ -575,8 +676,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}, uuid -> {
|
}, uuid -> {
|
||||||
loadingDialog.dismiss();
|
loadingDialog.dismiss();
|
||||||
if (uuid.isPresent()) {
|
if (uuid.isPresent()) {
|
||||||
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
Recipient recipient = Recipient.externalUsername(uuid.get(), contact.getNumber());
|
||||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||||
|
|
||||||
if (onContactSelectedListener != null) {
|
if (onContactSelectedListener != null) {
|
||||||
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||||
@@ -646,75 +747,22 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
private void markContactUnselected(@NonNull SelectedContact selectedContact) {
|
||||||
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
cursorRecyclerViewAdapter.removeFromSelectedContacts(selectedContact);
|
||||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||||
removeChipForContact(selectedContact);
|
contactChipViewModel.remove(selectedContact);
|
||||||
|
|
||||||
if (onContactSelectedListener != null) {
|
if (onContactSelectedListener != null) {
|
||||||
onContactSelectedListener.onSelectionChanged();
|
onContactSelectedListener.onSelectionChanged();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
private void handleSelectedContactsChanged(@NonNull List<SelectedContacts.Model> selectedContacts) {
|
||||||
for (int i = chipGroup.getChildCount() - 1; i >= 0; i--) {
|
contactChipAdapter.submitList(new MappingModelList(selectedContacts), this::smoothScrollChipsToEnd);
|
||||||
View v = chipGroup.getChildAt(i);
|
|
||||||
if (v instanceof ContactChip && contact.matches(((ContactChip) v).getContact())) {
|
|
||||||
chipGroup.removeView(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (getChipCount() == 0) {
|
if (selectedContacts.isEmpty()) {
|
||||||
setChipGroupVisibility(ConstraintSet.GONE);
|
setChipGroupVisibility(ConstraintSet.GONE);
|
||||||
}
|
} else {
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
setChipGroupVisibility(ConstraintSet.VISIBLE);
|
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().orNull());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 (selectionWarningLimitReachedExactly()) {
|
||||||
if (onSelectionLimitReachedListener != null) {
|
if (onSelectionLimitReachedListener != null) {
|
||||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||||
@@ -724,21 +772,25 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int getChipCount() {
|
private void addChipForSelectedContact(@NonNull SelectedContact selectedContact) {
|
||||||
int count = chipGroup.getChildCount() - CHIP_GROUP_EMPTY_CHILD_COUNT;
|
SimpleTask.run(getViewLifecycleOwner().getLifecycle(),
|
||||||
if (count < 0) throw new AssertionError();
|
() -> Recipient.resolved(selectedContact.getOrCreateRecipientId(requireContext())),
|
||||||
return count;
|
resolved -> contactChipViewModel.add(selectedContact));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void registerChipRecipientObserver(@NonNull ContactChip chip, @Nullable LiveRecipient recipient) {
|
private Unit onChipCloseIconClicked(SelectedContacts.Model model) {
|
||||||
if (recipient != null) {
|
markContactUnselected(model.getSelectedContact());
|
||||||
recipient.observe(getViewLifecycleOwner(), resolved -> {
|
if (onContactSelectedListener != null) {
|
||||||
if (chip.isAttachedToWindow()) {
|
onContactSelectedListener.onContactDeselected(Optional.of(model.getRecipient().getId()), model.getRecipient().getE164().orElse(null));
|
||||||
chip.setAvatar(glideRequests, resolved, null);
|
|
||||||
chip.setText(resolved.getShortDisplayName(chip.getContext()));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private void setChipGroupVisibility(int visibility) {
|
||||||
@@ -754,7 +806,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
ConstraintSet constraintSet = new ConstraintSet();
|
ConstraintSet constraintSet = new ConstraintSet();
|
||||||
constraintSet.clone(constraintLayout);
|
constraintSet.clone(constraintLayout);
|
||||||
constraintSet.setVisibility(R.id.chipGroupScrollContainer, visibility);
|
constraintSet.setVisibility(R.id.chipRecycler, visibility);
|
||||||
constraintSet.applyTo(constraintLayout);
|
constraintSet.applyTo(constraintLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,24 +815,30 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void smoothScrollChipsToEnd() {
|
private void smoothScrollChipsToEnd() {
|
||||||
int x = ViewUtil.isLtr(chipGroupScrollContainer) ? chipGroup.getWidth() : 0;
|
int x = ViewUtil.isLtr(chipRecycler) ? chipRecycler.getWidth() : 0;
|
||||||
chipGroupScrollContainer.smoothScrollTo(x, 0);
|
chipRecycler.smoothScrollBy(x, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface OnContactSelectedListener {
|
public interface OnContactSelectedListener {
|
||||||
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
|
/**
|
||||||
void onBeforeContactSelected(Optional<RecipientId> recipientId, @Nullable String number, Consumer<Boolean> callback);
|
* Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it.
|
||||||
void onContactDeselected(Optional<RecipientId> recipientId, @Nullable String number);
|
*/
|
||||||
|
void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, @Nullable String number, @NonNull Consumer<Boolean> callback);
|
||||||
|
|
||||||
|
void onContactDeselected(@NonNull Optional<RecipientId> recipientId, @Nullable String number);
|
||||||
|
|
||||||
void onSelectionChanged();
|
void onSelectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface OnSelectionLimitReachedListener {
|
public interface OnSelectionLimitReachedListener {
|
||||||
void onSuggestedLimitReached(int limit);
|
void onSuggestedLimitReached(int limit);
|
||||||
|
|
||||||
void onHardLimitReached(int limit);
|
void onHardLimitReached(int limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface ListCallback {
|
public interface ListCallback {
|
||||||
void onInvite();
|
void onInvite();
|
||||||
|
|
||||||
void onNewGroup(boolean forceV1);
|
void onNewGroup(boolean forceV1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,6 +846,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
void onBeginScroll();
|
void onBeginScroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface HeaderActionProvider {
|
||||||
|
@NonNull HeaderAction getHeaderAction();
|
||||||
|
}
|
||||||
|
|
||||||
public interface AbstractContactsCursorLoaderFactoryProvider {
|
public interface AbstractContactsCursorLoaderFactoryProvider {
|
||||||
@NonNull AbstractContactsCursorLoader.Factory get();
|
@NonNull AbstractContactsCursorLoader.Factory get();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,28 +17,25 @@ import android.widget.Toast;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
|
|
||||||
import org.signal.core.util.ThreadUtil;
|
import org.signal.core.util.ThreadUtil;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.signal.zkgroup.profiles.ProfileKey;
|
import org.signal.libsignal.protocol.IdentityKeyPair;
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
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.crypto.ProfileKeyUtil;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
|
||||||
import org.thoughtcrime.securesms.util.Base64;
|
import org.thoughtcrime.securesms.util.Base64;
|
||||||
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
import org.thoughtcrime.securesms.util.DynamicLanguage;
|
||||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||||
import org.whispersystems.libsignal.IdentityKeyPair;
|
|
||||||
import org.whispersystems.libsignal.InvalidKeyException;
|
|
||||||
import org.whispersystems.libsignal.ecc.Curve;
|
|
||||||
import org.whispersystems.libsignal.ecc.ECPublicKey;
|
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
|
||||||
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
||||||
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
|
import org.whispersystems.signalservice.internal.push.DeviceLimitExceededException;
|
||||||
|
|||||||
@@ -14,36 +14,34 @@ import android.widget.ImageView;
|
|||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.components.camera.CameraView;
|
import org.signal.qr.QrScannerView;
|
||||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
import org.signal.qr.kitkat.ScanListener;
|
||||||
import org.thoughtcrime.securesms.qr.ScanningThread;
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
|
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
|
|
||||||
public class DeviceAddFragment extends LoggingFragment {
|
public class DeviceAddFragment extends LoggingFragment {
|
||||||
|
|
||||||
private ViewGroup container;
|
private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
|
||||||
private LinearLayout overlay;
|
|
||||||
private ImageView devicesImage;
|
private ImageView devicesImage;
|
||||||
private CameraView scannerView;
|
private ScanListener scanListener;
|
||||||
private ScanningThread scanningThread;
|
|
||||||
private ScanListener scanListener;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.device_add_fragment);
|
ViewGroup 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);
|
|
||||||
|
|
||||||
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
QrScannerView scannerView = container.findViewById(R.id.scanner);
|
||||||
this.overlay.setOrientation(LinearLayout.HORIZONTAL);
|
this.devicesImage = container.findViewById(R.id.devices);
|
||||||
} else {
|
ViewCompat.setTransitionName(devicesImage, "devices");
|
||||||
this.overlay.setOrientation(LinearLayout.VERTICAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 21) {
|
if (Build.VERSION.SDK_INT >= 21) {
|
||||||
this.container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
container.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
|
||||||
@TargetApi(21)
|
@TargetApi(21)
|
||||||
@Override
|
@Override
|
||||||
public void onLayoutChange(View v, int left, int top, int right, int bottom,
|
public void onLayoutChange(View v, int left, int top, int right, int bottom,
|
||||||
@@ -59,52 +57,30 @@ public class DeviceAddFragment extends LoggingFragment {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.container;
|
scannerView.start(getViewLifecycleOwner(), FeatureFlags.useQrLegacyScan());
|
||||||
|
|
||||||
|
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scannerView.onResume();
|
|
||||||
this.scannerView.setPreviewCallback(scanningThread);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public ImageView getDevicesImage() {
|
public ImageView getDevicesImage() {
|
||||||
return devicesImage;
|
return devicesImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setScanListener(ScanListener scanListener) {
|
public void setScanListener(ScanListener scanListener) {
|
||||||
this.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 android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.core.view.ViewCompat;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
public class DeviceLinkFragment extends Fragment implements View.OnClickListener {
|
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) {
|
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
||||||
this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
|
this.container = (LinearLayout) inflater.inflate(R.layout.device_link_fragment, container, false);
|
||||||
this.container.findViewById(R.id.link_device).setOnClickListener(this);
|
this.container.findViewById(R.id.link_device).setOnClickListener(this);
|
||||||
|
ViewCompat.setTransitionName(container.findViewById(R.id.devices), "devices");
|
||||||
|
|
||||||
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
if (getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||||
container.setOrientation(LinearLayout.HORIZONTAL);
|
container.setOrientation(LinearLayout.HORIZONTAL);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package org.thoughtcrime.securesms;
|
|||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
@@ -22,7 +21,7 @@ import androidx.loader.app.LoaderManager;
|
|||||||
import androidx.loader.content.Loader;
|
import androidx.loader.content.Loader;
|
||||||
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
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.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
import org.thoughtcrime.securesms.database.loaders.DeviceListLoader;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import android.widget.TextView;
|
|||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.AnimRes;
|
import androidx.annotation.AnimRes;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||||
@@ -37,8 +38,8 @@ import org.thoughtcrime.securesms.util.ViewUtil;
|
|||||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
|
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture.Listener;
|
||||||
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask;
|
||||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
@@ -134,13 +135,13 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||||
callback.accept(true);
|
callback.accept(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {
|
public void onContactDeselected(@NonNull Optional<RecipientId> recipientId, String number) {
|
||||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
for (SelectedContact contact : contacts) {
|
for (SelectedContact contact : contacts) {
|
||||||
RecipientId recipientId = contact.getOrCreateRecipientId(context);
|
RecipientId recipientId = contact.getOrCreateRecipientId(context);
|
||||||
Recipient recipient = Recipient.resolved(recipientId);
|
Recipient recipient = Recipient.resolved(recipientId);
|
||||||
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
|
int subscriptionId = recipient.getDefaultSubscriptionId().orElse(-1);
|
||||||
|
|
||||||
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
|
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
|
||||||
|
|
||||||
|
|||||||
@@ -5,19 +5,27 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
|
||||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferLockedDialog;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
|
import org.thoughtcrime.securesms.stories.Stories;
|
||||||
|
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository;
|
||||||
|
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel;
|
||||||
import org.thoughtcrime.securesms.util.AppStartup;
|
import org.thoughtcrime.securesms.util.AppStartup;
|
||||||
import org.thoughtcrime.securesms.util.CachedInflater;
|
import org.thoughtcrime.securesms.util.CachedInflater;
|
||||||
import org.thoughtcrime.securesms.util.CommunicationActions;
|
import org.thoughtcrime.securesms.util.CommunicationActions;
|
||||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme;
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||||
|
import org.thoughtcrime.securesms.util.SplashScreenUtil;
|
||||||
|
import org.thoughtcrime.securesms.util.WindowUtil;
|
||||||
|
|
||||||
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||||
|
|
||||||
@@ -26,13 +34,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||||||
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
private final DynamicTheme dynamicTheme = new DynamicNoActionBarTheme();
|
||||||
private final MainNavigator navigator = new MainNavigator(this);
|
private final MainNavigator navigator = new MainNavigator(this);
|
||||||
|
|
||||||
private VoiceNoteMediaController mediaController;
|
private VoiceNoteMediaController mediaController;
|
||||||
|
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||||
|
|
||||||
public static @NonNull Intent clearTop(@NonNull Context context) {
|
public static @NonNull Intent clearTop(@NonNull Context context) {
|
||||||
Intent intent = new Intent(context, MainActivity.class);
|
Intent intent = new Intent(context, MainActivity.class);
|
||||||
|
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
|
|
||||||
return intent;
|
return intent;
|
||||||
@@ -42,22 +51,28 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||||||
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
protected void onCreate(Bundle savedInstanceState, boolean ready) {
|
||||||
AppStartup.getInstance().onCriticalRenderEventStart();
|
AppStartup.getInstance().onCriticalRenderEventStart();
|
||||||
super.onCreate(savedInstanceState, ready);
|
super.onCreate(savedInstanceState, ready);
|
||||||
|
|
||||||
setContentView(R.layout.main_activity);
|
setContentView(R.layout.main_activity);
|
||||||
|
|
||||||
mediaController = new VoiceNoteMediaController(this);
|
mediaController = new VoiceNoteMediaController(this);
|
||||||
navigator.onCreate(savedInstanceState);
|
|
||||||
|
ConversationListTabRepository repository = new ConversationListTabRepository();
|
||||||
|
ConversationListTabsViewModel.Factory factory = new ConversationListTabsViewModel.Factory(repository);
|
||||||
|
|
||||||
handleGroupLinkInIntent(getIntent());
|
handleGroupLinkInIntent(getIntent());
|
||||||
handleProxyInIntent(getIntent());
|
handleProxyInIntent(getIntent());
|
||||||
handleSignalMeIntent(getIntent());
|
handleSignalMeIntent(getIntent());
|
||||||
|
|
||||||
CachedInflater.from(this).clear();
|
CachedInflater.from(this).clear();
|
||||||
|
|
||||||
|
conversationListTabsViewModel = new ViewModelProvider(this, factory).get(ConversationListTabsViewModel.class);
|
||||||
|
updateTabVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Intent getIntent() {
|
public Intent getIntent() {
|
||||||
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
return super.getIntent().setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP |
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK |
|
Intent.FLAG_ACTIVITY_NEW_TASK |
|
||||||
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +97,14 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||||||
if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||||
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
|
OldDeviceTransferLockedDialog.show(getSupportFragmentManager());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTabVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStop() {
|
||||||
|
super.onStop();
|
||||||
|
SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings().getTheme());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -99,6 +122,17 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateTabVisibility() {
|
||||||
|
if (Stories.isFeatureEnabled()) {
|
||||||
|
findViewById(R.id.conversation_list_tabs).setVisibility(View.VISIBLE);
|
||||||
|
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorSurface2));
|
||||||
|
} else {
|
||||||
|
findViewById(R.id.conversation_list_tabs).setVisibility(View.GONE);
|
||||||
|
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_colorBackground));
|
||||||
|
conversationListTabsViewModel.onChatsSelected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public @NonNull MainNavigator getNavigator() {
|
public @NonNull MainNavigator getNavigator() {
|
||||||
return navigator;
|
return navigator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,13 @@ package org.thoughtcrime.securesms;
|
|||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity;
|
|
||||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||||
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment;
|
|
||||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment;
|
|
||||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||||
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
import org.thoughtcrime.securesms.insights.InsightsLauncher;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
@@ -36,16 +31,6 @@ public class MainNavigator {
|
|||||||
return ((MainActivity) activity).getNavigator();
|
return ((MainActivity) activity).getNavigator();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
|
||||||
if (savedInstanceState != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
getFragmentManager().beginTransaction()
|
|
||||||
.add(R.id.fragment_container, ConversationListFragment.newInstance())
|
|
||||||
.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return True if the back pressed was handled in our own custom way, false if it should be given
|
* @return True if the back pressed was handled in our own custom way, false if it should be given
|
||||||
* to the system to do the default behavior.
|
* to the system to do the default behavior.
|
||||||
@@ -74,14 +59,6 @@ public class MainNavigator {
|
|||||||
activity.startActivityForResult(AppSettingsActivity.home(activity), REQUEST_CONFIG_CHANGES);
|
activity.startActivityForResult(AppSettingsActivity.home(activity), REQUEST_CONFIG_CHANGES);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void goToArchiveList() {
|
|
||||||
getFragmentManager().beginTransaction()
|
|
||||||
.setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end)
|
|
||||||
.replace(R.id.fragment_container, ConversationListArchiveFragment.newInstance())
|
|
||||||
.addToBackStack(null)
|
|
||||||
.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void goToGroupCreation() {
|
public void goToGroupCreation() {
|
||||||
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
activity.startActivity(CreateGroupActivity.newIntent(activity));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ import org.signal.core.util.logging.Log;
|
|||||||
import org.thoughtcrime.securesms.animation.DepthPageTransformer;
|
import org.thoughtcrime.securesms.animation.DepthPageTransformer;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||||
import org.thoughtcrime.securesms.components.viewpager.ExtendedOnPageChangedListener;
|
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;
|
||||||
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord;
|
||||||
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
|
import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader;
|
||||||
@@ -67,7 +71,6 @@ import org.thoughtcrime.securesms.mms.PartAuthority;
|
|||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
import org.thoughtcrime.securesms.permissions.Permissions;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.sharing.ShareActivity;
|
|
||||||
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
import org.thoughtcrime.securesms.util.AttachmentUtil;
|
||||||
import org.thoughtcrime.securesms.util.DateUtils;
|
import org.thoughtcrime.securesms.util.DateUtils;
|
||||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||||
@@ -87,7 +90,8 @@ import java.util.Objects;
|
|||||||
public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
||||||
implements LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
|
implements LoaderManager.LoaderCallbacks<Pair<Cursor, Integer>>,
|
||||||
MediaRailAdapter.RailItemListener,
|
MediaRailAdapter.RailItemListener,
|
||||||
MediaPreviewFragment.Events
|
MediaPreviewFragment.Events,
|
||||||
|
VoiceNoteMediaControllerOwner
|
||||||
{
|
{
|
||||||
|
|
||||||
private final static String TAG = Log.tag(MediaPreviewActivity.class);
|
private final static String TAG = Log.tag(MediaPreviewActivity.class);
|
||||||
@@ -127,6 +131,8 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
private MediaDatabase.Sorting sorting;
|
private MediaDatabase.Sorting sorting;
|
||||||
private FullscreenHelper fullscreenHelper;
|
private FullscreenHelper fullscreenHelper;
|
||||||
|
|
||||||
|
private VoiceNoteMediaController voiceNoteMediaController;
|
||||||
|
|
||||||
private @Nullable Cursor cursor = null;
|
private @Nullable Cursor cursor = null;
|
||||||
|
|
||||||
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
|
public static @NonNull Intent intentFromMediaRecord(@NonNull Context context,
|
||||||
@@ -159,6 +165,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
|
|
||||||
setSupportActionBar(findViewById(R.id.toolbar));
|
setSupportActionBar(findViewById(R.id.toolbar));
|
||||||
|
|
||||||
|
voiceNoteMediaController = new VoiceNoteMediaController(this);
|
||||||
viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class);
|
viewModel = ViewModelProviders.of(this).get(MediaPreviewViewModel.class);
|
||||||
|
|
||||||
fullscreenHelper = new FullscreenHelper(this);
|
fullscreenHelper = new FullscreenHelper(this);
|
||||||
@@ -203,23 +210,25 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
else from = "";
|
else from = "";
|
||||||
|
|
||||||
if (showThread) {
|
if (showThread) {
|
||||||
String to = null;
|
String titleText = null;
|
||||||
Recipient threadRecipient = mediaItem.threadRecipient;
|
Recipient threadRecipient = mediaItem.threadRecipient;
|
||||||
|
|
||||||
if (threadRecipient != null) {
|
if (threadRecipient != null) {
|
||||||
if (mediaItem.outgoing || threadRecipient.isGroup()) {
|
if (mediaItem.outgoing) {
|
||||||
if (threadRecipient.isSelf()) {
|
if (threadRecipient.isSelf()) {
|
||||||
from = getString(R.string.note_to_self);
|
titleText = getString(R.string.note_to_self);
|
||||||
} else {
|
} else {
|
||||||
to = threadRecipient.getDisplayName(this);
|
titleText = getString(R.string.MediaPreviewActivity_you_to_s, threadRecipient.getDisplayName(this));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
to = getString(R.string.MediaPreviewActivity_you);
|
if (threadRecipient.isGroup()) {
|
||||||
|
titleText = getString(R.string.MediaPreviewActivity_s_to_s, from, threadRecipient.getDisplayName(this));
|
||||||
|
} else {
|
||||||
|
titleText = getString(R.string.MediaPreviewActivity_s_to_you, from);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return titleText != null ? titleText : from;
|
||||||
return to != null ? getString(R.string.MediaPreviewActivity_s_to_s, from, to)
|
|
||||||
: from;
|
|
||||||
} else {
|
} else {
|
||||||
return from;
|
return from;
|
||||||
}
|
}
|
||||||
@@ -286,7 +295,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
|
|
||||||
anchorMarginsToBottomInsets(detailsContainer);
|
anchorMarginsToBottomInsets(detailsContainer);
|
||||||
|
|
||||||
fullscreenHelper.configureToolbarSpacer(findViewById(R.id.toolbar_cutout_spacer));
|
fullscreenHelper.configureToolbarLayout(findViewById(R.id.toolbar_cutout_spacer), findViewById(R.id.toolbar));
|
||||||
|
|
||||||
fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
|
fullscreenHelper.showAndHideWithSystemUI(getWindow(), detailsContainer, toolbarLayout);
|
||||||
}
|
}
|
||||||
@@ -386,10 +395,12 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
MediaItem mediaItem = getCurrentMediaItem();
|
MediaItem mediaItem = getCurrentMediaItem();
|
||||||
|
|
||||||
if (mediaItem != null) {
|
if (mediaItem != null) {
|
||||||
Intent composeIntent = new Intent(this, ShareActivity.class);
|
MultiselectForwardFragmentArgs.create(
|
||||||
composeIntent.putExtra(Intent.EXTRA_STREAM, mediaItem.uri);
|
this,
|
||||||
composeIntent.setType(mediaItem.type);
|
mediaItem.uri,
|
||||||
startActivity(composeIntent);
|
mediaItem.type,
|
||||||
|
args -> MultiselectForwardFragment.showBottomSheet(getSupportFragmentManager(), args)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +564,8 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
cursor = Objects.requireNonNull(data.first);
|
cursor = Objects.requireNonNull(data.first);
|
||||||
|
|
||||||
|
viewModel.setCursor(this, cursor, leftIsRecent);
|
||||||
|
|
||||||
int mediaPosition = Objects.requireNonNull(data.second);
|
int mediaPosition = Objects.requireNonNull(data.second);
|
||||||
|
|
||||||
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
|
CursorPagerAdapter oldAdapter = (CursorPagerAdapter) mediaPager.getAdapter();
|
||||||
@@ -565,13 +578,13 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
oldAdapter.setActive(true);
|
oldAdapter.setActive(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.setCursor(this, cursor, leftIsRecent);
|
if (oldAdapter == null || restartItem >= 0) {
|
||||||
|
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
||||||
|
mediaPager.setCurrentItem(item);
|
||||||
|
|
||||||
int item = restartItem >= 0 ? restartItem : mediaPosition;
|
if (item == 0) {
|
||||||
mediaPager.setCurrentItem(item);
|
viewPagerListener.onPageSelected(0);
|
||||||
|
}
|
||||||
if (item == 0) {
|
|
||||||
viewPagerListener.onPageSelected(0);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
mediaNotAvailable();
|
mediaNotAvailable();
|
||||||
@@ -595,6 +608,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMediaReady() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||||
|
return voiceNoteMediaController;
|
||||||
|
}
|
||||||
|
|
||||||
private class ViewPagerListener extends ExtendedOnPageChangedListener {
|
private class ViewPagerListener extends ExtendedOnPageChangedListener {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.DialogInterface;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|||||||
@@ -21,10 +21,11 @@ import android.os.Bundle;
|
|||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.contacts.sync.DirectoryHelper;
|
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||||
@@ -32,11 +33,11 @@ import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
|||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
import org.signal.core.util.concurrent.SimpleTask;
|
||||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,7 +62,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
public void onBeforeContactSelected(@NonNull Optional<RecipientId> recipientId, String number, @NonNull Consumer<Boolean> callback) {
|
||||||
if (recipientId.isPresent()) {
|
if (recipientId.isPresent()) {
|
||||||
launch(Recipient.resolved(recipientId.get()));
|
launch(Recipient.resolved(recipientId.get()));
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +79,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||||||
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
|
if (!resolved.isRegistered() || !resolved.hasServiceId()) {
|
||||||
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
|
Log.i(TAG, "[onContactSelected] Not registered or no UUID. Doing a directory refresh.");
|
||||||
try {
|
try {
|
||||||
DirectoryHelper.refreshDirectoryFor(this, resolved, false);
|
ContactDiscovery.refresh(this, resolved, false);
|
||||||
resolved = Recipient.resolved(resolved.getId());
|
resolved = Recipient.resolved(resolved.getId());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
|
Log.w(TAG, "[onContactSelected] Failed to refresh directory for new contact.");
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ package org.thoughtcrime.securesms;
|
|||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
|
||||||
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
import org.thoughtcrime.securesms.crypto.MasterSecret;
|
||||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
@@ -62,8 +61,8 @@ public class PassphraseCreateActivity extends PassphraseActivity {
|
|||||||
passphrase);
|
passphrase);
|
||||||
|
|
||||||
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
|
MasterSecretUtil.generateAsymmetricMasterSecret(PassphraseCreateActivity.this, masterSecret);
|
||||||
SignalStore.account().generateAciIdentityKey();
|
SignalStore.account().generateAciIdentityKeyIfNecessary();
|
||||||
SignalStore.account().generatePniIdentityKey();
|
SignalStore.account().generatePniIdentityKeyIfNecessary();
|
||||||
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
|
VersionTracker.updateLastSeenVersion(PassphraseCreateActivity.this);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
return STATE_WELCOME_PUSH_SCREEN;
|
return STATE_WELCOME_PUSH_SCREEN;
|
||||||
} else if (SignalStore.storageService().needsAccountRestore()) {
|
} else if (SignalStore.storageService().needsAccountRestore()) {
|
||||||
return STATE_ENTER_SIGNAL_PIN;
|
return STATE_ENTER_SIGNAL_PIN;
|
||||||
|
} else if (userHasSkippedOrForgottenPin()) {
|
||||||
|
return STATE_CREATE_SIGNAL_PIN;
|
||||||
} else if (userMustSetProfileName()) {
|
} else if (userMustSetProfileName()) {
|
||||||
return STATE_CREATE_PROFILE_NAME;
|
return STATE_CREATE_PROFILE_NAME;
|
||||||
} else if (userMustCreateSignalPin()) {
|
} else if (userMustCreateSignalPin()) {
|
||||||
@@ -190,6 +192,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
|
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().lastPinCreateFailed() && !SignalStore.kbsValues().hasOptedOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean userHasSkippedOrForgottenPin() {
|
||||||
|
return !SignalStore.registrationValues().isRegistrationComplete() && !SignalStore.kbsValues().hasPin() && !SignalStore.kbsValues().hasOptedOut() && SignalStore.kbsValues().isPinForgottenOrSkipped();
|
||||||
|
}
|
||||||
|
|
||||||
private boolean userMustSetProfileName() {
|
private boolean userMustSetProfileName() {
|
||||||
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
return !SignalStore.registrationValues().isRegistrationComplete() && Recipient.self().getProfileName().isEmpty();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,147 +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 org.whispersystems.libsignal.util.guava.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.<CharSequence>absent(), Optional.<Integer>absent());
|
|
||||||
}
|
|
||||||
|
|
||||||
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.fromNullable(TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(in)),
|
|
||||||
in.readInt() == 1 ? Optional.of(in.readInt()) : Optional.absent());
|
|
||||||
}
|
|
||||||
|
|
||||||
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.orNull(), 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,225 +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.libsignal.util.guava.Optional;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.LinkedList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
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.absent();
|
|
||||||
private Optional<TransportOption> selectedOption = Optional.absent();
|
|
||||||
|
|
||||||
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.absent();
|
|
||||||
|
|
||||||
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.fromNullable(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(defaultSubscriptionId.or(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().or(-1)) {
|
|
||||||
return transportOption;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void disableTransport(Type type) {
|
|
||||||
TransportOption selected = selectedOption.orNull();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -39,16 +39,19 @@ import androidx.appcompat.app.AlertDialog;
|
|||||||
import androidx.appcompat.app.AppCompatDelegate;
|
import androidx.appcompat.app.AppCompatDelegate;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.core.util.Consumer;
|
import androidx.core.util.Consumer;
|
||||||
import androidx.lifecycle.ViewModelProviders;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
import androidx.window.DisplayFeature;
|
import androidx.window.DisplayFeature;
|
||||||
import androidx.window.FoldingFeature;
|
import androidx.window.FoldingFeature;
|
||||||
import androidx.window.WindowLayoutInfo;
|
import androidx.window.WindowLayoutInfo;
|
||||||
|
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
import org.greenrobot.eventbus.EventBus;
|
||||||
import org.greenrobot.eventbus.Subscribe;
|
import org.greenrobot.eventbus.Subscribe;
|
||||||
import org.greenrobot.eventbus.ThreadMode;
|
import org.greenrobot.eventbus.ThreadMode;
|
||||||
import org.signal.core.util.concurrent.SignalExecutors;
|
import org.signal.core.util.concurrent.SignalExecutors;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.signal.libsignal.protocol.IdentityKey;
|
||||||
import org.thoughtcrime.securesms.components.TooltipPopup;
|
import org.thoughtcrime.securesms.components.TooltipPopup;
|
||||||
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
|
||||||
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
|
||||||
@@ -75,16 +78,19 @@ import org.thoughtcrime.securesms.util.FullscreenHelper;
|
|||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
|
import org.thoughtcrime.securesms.util.VibrateUtil;
|
||||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||||
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState;
|
||||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
|
||||||
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
import org.whispersystems.signalservice.api.messages.calls.HangupMessage;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
|
|
||||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||||
|
|
||||||
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback {
|
||||||
@@ -92,6 +98,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private static final String TAG = Log.tag(WebRtcCallActivity.class);
|
private static final String TAG = Log.tag(WebRtcCallActivity.class);
|
||||||
|
|
||||||
private static final int STANDARD_DELAY_FINISH = 1000;
|
private static final int STANDARD_DELAY_FINISH = 1000;
|
||||||
|
private static final int VIBRATE_DURATION = 50;
|
||||||
|
|
||||||
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION";
|
||||||
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
|
public static final String DENY_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".DENY_ACTION";
|
||||||
@@ -107,10 +114,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private TooltipPopup videoTooltip;
|
private TooltipPopup videoTooltip;
|
||||||
private WebRtcCallViewModel viewModel;
|
private WebRtcCallViewModel viewModel;
|
||||||
private boolean enableVideoIfAvailable;
|
private boolean enableVideoIfAvailable;
|
||||||
|
private boolean hasWarnedAboutBluetooth;
|
||||||
private androidx.window.WindowManager windowManager;
|
private androidx.window.WindowManager windowManager;
|
||||||
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||||
private ThrottledDebouncer requestNewSizesThrottle;
|
private ThrottledDebouncer requestNewSizesThrottle;
|
||||||
|
|
||||||
|
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void attachBaseContext(@NonNull Context newBase) {
|
protected void attachBaseContext(@NonNull Context newBase) {
|
||||||
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES);
|
||||||
@@ -153,6 +163,16 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onStart() {
|
||||||
|
super.onStart();
|
||||||
|
|
||||||
|
ephemeralStateDisposable = ApplicationDependencies.getSignalCallManager()
|
||||||
|
.ephemeralStates()
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(viewModel::updateFromEphemeralState);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onResume() {
|
public void onResume() {
|
||||||
Log.i(TAG, "onResume()");
|
Log.i(TAG, "onResume()");
|
||||||
@@ -193,6 +213,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
Log.i(TAG, "onStop");
|
Log.i(TAG, "onStop");
|
||||||
super.onStop();
|
super.onStop();
|
||||||
|
|
||||||
|
ephemeralStateDisposable.dispose();
|
||||||
|
|
||||||
if (!isInPipMode() || isFinishing()) {
|
if (!isInPipMode() || isFinishing()) {
|
||||||
EventBus.getDefault().unregister(this);
|
EventBus.getDefault().unregister(this);
|
||||||
requestNewSizesThrottle.clear();
|
requestNewSizesThrottle.clear();
|
||||||
@@ -285,7 +307,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
|
|
||||||
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
||||||
|
|
||||||
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
|
viewModel = new ViewModelProvider(this, factory).get(WebRtcCallViewModel.class);
|
||||||
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||||
viewModel.setIsInPipMode(isInPipMode());
|
viewModel.setIsInPipMode(isInPipMode());
|
||||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||||
@@ -295,7 +317,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
|
|
||||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
||||||
viewModel.getOrientationAndLandscapeEnabled(),
|
viewModel.getOrientationAndLandscapeEnabled(),
|
||||||
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
viewModel.getEphemeralState(),
|
||||||
|
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||||
.observe(this, p -> callScreen.updateCallParticipants(p));
|
.observe(this, p -> callScreen.updateCallParticipants(p));
|
||||||
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
|
viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate);
|
||||||
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
|
viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent);
|
||||||
@@ -501,6 +524,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleCallReconnecting() {
|
||||||
|
callScreen.setStatus(getString(R.string.WebRtcCallActivity__reconnecting));
|
||||||
|
VibrateUtil.vibrate(this, VIBRATE_DURATION);
|
||||||
|
}
|
||||||
|
|
||||||
private void handleRecipientUnavailable() {
|
private void handleRecipientUnavailable() {
|
||||||
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class);
|
||||||
callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
|
callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable));
|
||||||
@@ -623,6 +651,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
handleCallPreJoin(event); break;
|
handleCallPreJoin(event); break;
|
||||||
case CALL_CONNECTED:
|
case CALL_CONNECTED:
|
||||||
handleCallConnected(event); break;
|
handleCallConnected(event); break;
|
||||||
|
case CALL_RECONNECTING:
|
||||||
|
handleCallReconnecting(); break;
|
||||||
case NETWORK_FAILURE:
|
case NETWORK_FAILURE:
|
||||||
handleServerFailure(); break;
|
handleServerFailure(); break;
|
||||||
case CALL_RINGING:
|
case CALL_RINGING:
|
||||||
@@ -659,6 +689,17 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
enableVideoIfAvailable = false;
|
enableVideoIfAvailable = false;
|
||||||
handleSetMuteVideo(false);
|
handleSetMuteVideo(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.getBluetoothPermissionDenied() && !hasWarnedAboutBluetooth && !isFinishing()) {
|
||||||
|
new MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle(R.string.WebRtcCallActivity__bluetooth_permission_denied)
|
||||||
|
.setMessage(R.string.WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call)
|
||||||
|
.setPositiveButton(R.string.WebRtcCallActivity__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(this)))
|
||||||
|
.setNegativeButton(R.string.WebRtcCallActivity__not_now, null)
|
||||||
|
.show();
|
||||||
|
|
||||||
|
hasWarnedAboutBluetooth = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||||
@@ -807,7 +848,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
if (feature.isPresent()) {
|
if (feature.isPresent()) {
|
||||||
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
|
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
|
||||||
Rect bounds = foldingFeature.getBounds();
|
Rect bounds = foldingFeature.getBounds();
|
||||||
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
|
if (foldingFeature.isSeparating()) {
|
||||||
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
|
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
|
||||||
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
|
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.thoughtcrime.securesms.animation.transitions
|
||||||
|
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.transition.Transition
|
||||||
|
import android.transition.TransitionValues
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.animation.doOnEnd
|
||||||
|
import androidx.core.animation.doOnStart
|
||||||
|
|
||||||
|
@RequiresApi(21)
|
||||||
|
class CrossfaderTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WIDTH = "CrossfaderTransition.WIDTH"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||||
|
if (transitionValues.view is Crossfadeable) {
|
||||||
|
transitionValues.values[WIDTH] = transitionValues.view.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||||
|
if (transitionValues.view is Crossfadeable) {
|
||||||
|
transitionValues.values[WIDTH] = transitionValues.view.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||||
|
if (startValues == null || endValues == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val startWidth = (startValues.values[WIDTH] ?: 0) as Int
|
||||||
|
val endWidth = (endValues.values[WIDTH] ?: 0) as Int
|
||||||
|
val view: Crossfadeable = endValues.view as? Crossfadeable ?: return null
|
||||||
|
val reverse = startWidth > endWidth
|
||||||
|
|
||||||
|
return ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
addUpdateListener {
|
||||||
|
view.onCrossfadeAnimationUpdated(it.animatedValue as Float, reverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
doOnStart {
|
||||||
|
view.onCrossfadeStarted(reverse)
|
||||||
|
}
|
||||||
|
|
||||||
|
doOnEnd {
|
||||||
|
view.onCrossfadeFinished(reverse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Crossfadeable {
|
||||||
|
fun onCrossfadeAnimationUpdated(progress: Float, reverse: Boolean)
|
||||||
|
fun onCrossfadeStarted(reverse: Boolean)
|
||||||
|
fun onCrossfadeFinished(reverse: Boolean)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.thoughtcrime.securesms.animation.transitions
|
||||||
|
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.transition.Transition
|
||||||
|
import android.transition.TransitionValues
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
|
||||||
|
@RequiresApi(21)
|
||||||
|
class FabElevationFadeTransform(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ELEVATION = "CrossfaderTransition.ELEVATION"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||||
|
if (transitionValues.view is FloatingActionButton) {
|
||||||
|
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||||
|
if (transitionValues.view is FloatingActionButton) {
|
||||||
|
transitionValues.values[ELEVATION] = transitionValues.view.elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||||
|
if (startValues?.view !is FloatingActionButton || endValues?.view !is FloatingActionButton) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val startElevation = startValues.view.elevation
|
||||||
|
val endElevation = endValues.view.elevation
|
||||||
|
if (startElevation == endElevation) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValueAnimator.ofFloat(
|
||||||
|
startValues.values[ELEVATION] as Float,
|
||||||
|
endValues.values[ELEVATION] as Float
|
||||||
|
).apply {
|
||||||
|
addUpdateListener {
|
||||||
|
val elevation = it.animatedValue as Float
|
||||||
|
endValues.view.elevation = elevation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,12 +9,12 @@ import org.thoughtcrime.securesms.blurhash.BlurHash;
|
|||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||||
import org.thoughtcrime.securesms.util.Base64;
|
import org.thoughtcrime.securesms.util.Base64;
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
|
||||||
|
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public class PointerAttachment extends Attachment {
|
public class PointerAttachment extends Attachment {
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ public class PointerAttachment extends Attachment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Optional<Attachment> forPointer(Optional<SignalServiceAttachment> pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) {
|
public static Optional<Attachment> forPointer(Optional<SignalServiceAttachment> pointer, @Nullable StickerLocator stickerLocator, @Nullable String fastPreflightId) {
|
||||||
if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.absent();
|
if (!pointer.isPresent() || !pointer.get().isPointer()) return Optional.empty();
|
||||||
|
|
||||||
String encodedKey = null;
|
String encodedKey = null;
|
||||||
|
|
||||||
@@ -103,12 +103,12 @@ public class PointerAttachment extends Attachment {
|
|||||||
|
|
||||||
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
return Optional.of(new PointerAttachment(pointer.get().getContentType(),
|
||||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||||
pointer.get().asPointer().getSize().or(0),
|
pointer.get().asPointer().getSize().orElse(0),
|
||||||
pointer.get().asPointer().getFileName().orNull(),
|
pointer.get().asPointer().getFileName().orElse(null),
|
||||||
pointer.get().asPointer().getCdnNumber(),
|
pointer.get().asPointer().getCdnNumber(),
|
||||||
pointer.get().asPointer().getRemoteId().toString(),
|
pointer.get().asPointer().getRemoteId().toString(),
|
||||||
encodedKey, null,
|
encodedKey, null,
|
||||||
pointer.get().asPointer().getDigest().orNull(),
|
pointer.get().asPointer().getDigest().orElse(null),
|
||||||
fastPreflightId,
|
fastPreflightId,
|
||||||
pointer.get().asPointer().getVoiceNote(),
|
pointer.get().asPointer().getVoiceNote(),
|
||||||
pointer.get().asPointer().isBorderless(),
|
pointer.get().asPointer().isBorderless(),
|
||||||
@@ -116,9 +116,9 @@ public class PointerAttachment extends Attachment {
|
|||||||
pointer.get().asPointer().getWidth(),
|
pointer.get().asPointer().getWidth(),
|
||||||
pointer.get().asPointer().getHeight(),
|
pointer.get().asPointer().getHeight(),
|
||||||
pointer.get().asPointer().getUploadTimestamp(),
|
pointer.get().asPointer().getUploadTimestamp(),
|
||||||
pointer.get().asPointer().getCaption().orNull(),
|
pointer.get().asPointer().getCaption().orElse(null),
|
||||||
stickerLocator,
|
stickerLocator,
|
||||||
BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orNull())));
|
BlurHash.parseOrNull(pointer.get().asPointer().getBlurHash().orElse(null))));
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,13 +127,13 @@ public class PointerAttachment extends Attachment {
|
|||||||
|
|
||||||
return Optional.of(new PointerAttachment(pointer.getContentType(),
|
return Optional.of(new PointerAttachment(pointer.getContentType(),
|
||||||
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
AttachmentDatabase.TRANSFER_PROGRESS_PENDING,
|
||||||
thumbnail != null ? thumbnail.asPointer().getSize().or(0) : 0,
|
thumbnail != null ? thumbnail.asPointer().getSize().orElse(0) : 0,
|
||||||
pointer.getFileName(),
|
pointer.getFileName(),
|
||||||
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
thumbnail != null ? thumbnail.asPointer().getCdnNumber() : 0,
|
||||||
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
thumbnail != null ? thumbnail.asPointer().getRemoteId().toString() : "0",
|
||||||
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
thumbnail != null && thumbnail.asPointer().getKey() != null ? Base64.encodeBytes(thumbnail.asPointer().getKey()) : null,
|
||||||
null,
|
null,
|
||||||
thumbnail != null ? thumbnail.asPointer().getDigest().orNull() : null,
|
thumbnail != null ? thumbnail.asPointer().getDigest().orElse(null) : null,
|
||||||
null,
|
null,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
@@ -141,7 +141,7 @@ public class PointerAttachment extends Attachment {
|
|||||||
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
thumbnail != null ? thumbnail.asPointer().getWidth() : 0,
|
||||||
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
thumbnail != null ? thumbnail.asPointer().getHeight() : 0,
|
||||||
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
thumbnail != null ? thumbnail.asPointer().getUploadTimestamp() : 0,
|
||||||
thumbnail != null ? thumbnail.asPointer().getCaption().orNull() : null,
|
thumbnail != null ? thumbnail.asPointer().getCaption().orElse(null) : null,
|
||||||
null,
|
null,
|
||||||
null));
|
null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import org.signal.core.util.concurrent.SignalExecutors;
|
|||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
import org.thoughtcrime.securesms.util.concurrent.SettableFuture;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
|||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||||
import org.thoughtcrime.securesms.util.MediaUtil
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
import org.whispersystems.libsignal.util.guava.Optional
|
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.Optional
|
||||||
import javax.annotation.meta.Exhaustive
|
import javax.annotation.meta.Exhaustive
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,6 +128,6 @@ object AvatarRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun createMedia(uri: Uri, size: Long): Media {
|
private fun createMedia(uri: Uri, size: Long): Media {
|
||||||
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.absent(), Optional.absent(), Optional.absent())
|
return Media(uri, MediaUtil.IMAGE_JPEG, System.currentTimeMillis(), DIMENSIONS, DIMENSIONS, size, 0, false, false, Optional.empty(), Optional.empty(), Optional.empty())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class TextAvatarDrawable(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun draw(canvas: Canvas) {
|
override fun draw(canvas: Canvas) {
|
||||||
val textSize = Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f)
|
|
||||||
val width = bounds.width()
|
val width = bounds.width()
|
||||||
|
val textSize = Avatars.getTextSizeForLength(context, avatar.text, width * 0.8f, width * 0.45f)
|
||||||
val candidates = EmojiProvider.getCandidates(avatar.text)
|
val candidates = EmojiProvider.getCandidates(avatar.text)
|
||||||
|
|
||||||
textPaint.textSize = textSize
|
textPaint.textSize = textSize
|
||||||
|
|||||||
@@ -81,14 +81,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearButton.visible = state.canClear
|
clearButton.visible = state.canClear
|
||||||
|
saveButton.isClickable = state.canSave
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
||||||
val selectedPosition = items.indexOfFirst { it.isSelected }
|
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||||
@@ -104,6 +97,11 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
|||||||
photoButton.setOnIconClickedListener { openGallery() }
|
photoButton.setOnIconClickedListener { openGallery() }
|
||||||
textButton.setOnIconClickedListener { openTextEditor(null) }
|
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||||
saveButton.setOnClickListener { v ->
|
saveButton.setOnClickListener { v ->
|
||||||
|
if (!saveButton.isEnabled) {
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
saveButton.isEnabled = false
|
||||||
viewModel.save(
|
viewModel.save(
|
||||||
{
|
{
|
||||||
setFragmentResult(
|
setFragmentResult(
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepositor
|
|||||||
private val isNewGroup: Boolean,
|
private val isNewGroup: Boolean,
|
||||||
private val groupAvatarMedia: Media?
|
private val groupAvatarMedia: Media?
|
||||||
) : ViewModelProvider.Factory {
|
) : 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) {
|
val viewModel = if (groupId == null && !isNewGroup) {
|
||||||
SelfAvatarPickerViewModel(repository)
|
SelfAvatarPickerViewModel(repository)
|
||||||
} else if (groupId == null) {
|
} else if (groupId == null) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
|
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)))
|
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel()
|
|||||||
fun getCurrentAvatar() = store.state.currentAvatar
|
fun getCurrentAvatar() = store.state.currentAvatar
|
||||||
|
|
||||||
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
|
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)))
|
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.view
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||||
|
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.stories.Stories
|
||||||
|
import org.thoughtcrime.securesms.util.visible
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AvatarView encapsulating the AvatarImageView and decorations.
|
||||||
|
*/
|
||||||
|
class AvatarView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : FrameLayout(context, attrs) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
inflate(context, R.layout.avatar_view, this)
|
||||||
|
|
||||||
|
isClickable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val avatar: AvatarImageView = findViewById<AvatarImageView>(R.id.avatar_image_view).apply {
|
||||||
|
initialize(context, attrs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val storyRing: View = findViewById(R.id.avatar_story_ring)
|
||||||
|
|
||||||
|
private fun showStoryRing(hasUnreadStory: Boolean) {
|
||||||
|
if (!Stories.isFeatureEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storyRing.visible = true
|
||||||
|
storyRing.isActivated = hasUnreadStory
|
||||||
|
|
||||||
|
avatar.scaleX = 0.8f
|
||||||
|
avatar.scaleY = 0.8f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideStoryRing() {
|
||||||
|
storyRing.visible = false
|
||||||
|
|
||||||
|
avatar.scaleX = 1f
|
||||||
|
avatar.scaleY = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasStory(): Boolean {
|
||||||
|
return storyRing.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStoryRingFromState(storyViewState: StoryViewState) {
|
||||||
|
when (storyViewState) {
|
||||||
|
StoryViewState.NONE -> hideStoryRing()
|
||||||
|
StoryViewState.UNVIEWED -> showStoryRing(true)
|
||||||
|
StoryViewState.VIEWED -> showStoryRing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays Note-to-Self
|
||||||
|
*/
|
||||||
|
fun displayChatAvatar(recipient: Recipient) {
|
||||||
|
avatar.setAvatar(recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays Note-to-Self
|
||||||
|
*/
|
||||||
|
fun displayChatAvatar(requestManager: GlideRequests, recipient: Recipient, isQuickContactEnabled: Boolean) {
|
||||||
|
avatar.setAvatar(requestManager, recipient, isQuickContactEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays Profile image
|
||||||
|
*/
|
||||||
|
fun displayProfileAvatar(recipient: Recipient) {
|
||||||
|
avatar.setRecipient(recipient)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFallbackPhotoProvider(fallbackPhotoProvider: Recipient.FallbackPhotoProvider) {
|
||||||
|
avatar.setFallbackPhotoProvider(fallbackPhotoProvider)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disableQuickContact() {
|
||||||
|
avatar.disableQuickContact()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import androidx.annotation.RequiresApi;
|
|||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.R;
|
import org.thoughtcrime.securesms.R;
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
@@ -110,7 +112,7 @@ public class BackupDialog {
|
|||||||
|
|
||||||
@RequiresApi(29)
|
@RequiresApi(29)
|
||||||
public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) {
|
public static void showChooseBackupLocationDialog(@NonNull Fragment fragment, int requestCode) {
|
||||||
new AlertDialog.Builder(fragment.requireContext())
|
new MaterialAlertDialogBuilder(fragment.requireContext())
|
||||||
.setView(R.layout.backup_choose_location_dialog)
|
.setView(R.layout.backup_choose_location_dialog)
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
.setNegativeButton(android.R.string.cancel, (dialog, which) -> {
|
||||||
@@ -141,7 +143,7 @@ public class BackupDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) {
|
public static void showDisableBackupDialog(@NonNull Context context, @NonNull Runnable onBackupsDisabled) {
|
||||||
new AlertDialog.Builder(context)
|
new MaterialAlertDialogBuilder(context)
|
||||||
.setTitle(R.string.BackupDialog_delete_backups)
|
.setTitle(R.string.BackupDialog_delete_backups)
|
||||||
.setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
|
.setMessage(R.string.BackupDialog_disable_and_delete_all_local_backups)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
@@ -157,12 +159,12 @@ public class BackupDialog {
|
|||||||
public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
|
public static void showVerifyBackupPassphraseDialog(@NonNull Context context) {
|
||||||
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
|
View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null);
|
||||||
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
|
EditText prompt = view.findViewById(R.id.restore_passphrase_input);
|
||||||
AlertDialog dialog = new AlertDialog.Builder(context)
|
AlertDialog dialog = new MaterialAlertDialogBuilder(context)
|
||||||
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
|
.setTitle(R.string.BackupDialog_enter_backup_passphrase_to_verify)
|
||||||
.setView(view)
|
.setView(view)
|
||||||
.setPositiveButton(R.string.BackupDialog_verify, null)
|
.setPositiveButton(R.string.BackupDialog_verify, null)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
|
||||||
positiveButton.setEnabled(false);
|
positiveButton.setEnabled(false);
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.greenrobot.eventbus.EventBus;
|
import org.greenrobot.eventbus.EventBus;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
|||||||
import org.greenrobot.eventbus.EventBus;
|
import org.greenrobot.eventbus.EventBus;
|
||||||
import org.signal.core.util.Conversions;
|
import org.signal.core.util.Conversions;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.signal.libsignal.protocol.kdf.HKDFv3;
|
||||||
|
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||||
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||||
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
import org.thoughtcrime.securesms.crypto.AttachmentSecret;
|
||||||
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
import org.thoughtcrime.securesms.crypto.ClassicDecryptingPartInputStream;
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
|
||||||
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
import org.thoughtcrime.securesms.crypto.ModernDecryptingPartInputStream;
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
import org.thoughtcrime.securesms.database.EmojiSearchDatabase;
|
||||||
@@ -47,13 +48,11 @@ import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
|||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.util.CursorUtil;
|
import org.signal.core.util.CursorUtil;
|
||||||
import org.thoughtcrime.securesms.util.SetUtil;
|
import org.signal.core.util.SetUtil;
|
||||||
import org.thoughtcrime.securesms.util.Stopwatch;
|
import org.thoughtcrime.securesms.util.Stopwatch;
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
@@ -198,7 +197,9 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
throwIfCanceled(cancellationSignal);
|
throwIfCanceled(cancellationSignal);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
|
try (InputStream inputStream = avatar.getInputStream()) {
|
||||||
|
outputStream.write(avatar.getFilename(), inputStream, avatar.getLength());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,6 +379,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size);
|
||||||
|
inputStream.close();
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
@@ -396,8 +398,9 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
|
|
||||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) {
|
||||||
outputStream.writeSticker(rowId, inputStream, size);
|
outputStream.writeSticker(rowId, inputStream, size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import org.greenrobot.eventbus.EventBus;
|
|||||||
import org.signal.core.util.Conversions;
|
import org.signal.core.util.Conversions;
|
||||||
import org.signal.core.util.StreamUtil;
|
import org.signal.core.util.StreamUtil;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.signal.libsignal.protocol.kdf.HKDFv3;
|
||||||
|
import org.signal.libsignal.protocol.util.ByteUtil;
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
import org.thoughtcrime.securesms.backup.BackupProtos.Attachment;
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame;
|
||||||
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion;
|
||||||
@@ -36,9 +38,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
|||||||
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
import org.thoughtcrime.securesms.profiles.AvatarHelper;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
import org.thoughtcrime.securesms.util.BackupUtil;
|
import org.thoughtcrime.securesms.util.BackupUtil;
|
||||||
import org.thoughtcrime.securesms.util.SqlUtil;
|
import org.signal.core.util.SqlUtil;
|
||||||
import org.whispersystems.libsignal.kdf.HKDFv3;
|
|
||||||
import org.whispersystems.libsignal.util.ByteUtil;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@@ -69,6 +69,18 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
private static final String TAG = Log.tag(FullBackupImporter.class);
|
private static final String TAG = Log.tag(FullBackupImporter.class);
|
||||||
|
|
||||||
|
private static final String[] TABLES_TO_DROP_FIRST = {
|
||||||
|
"distribution_list_member",
|
||||||
|
"distribution_list",
|
||||||
|
"message_send_log_recipients",
|
||||||
|
"msl_recipient",
|
||||||
|
"msl_message",
|
||||||
|
"reaction",
|
||||||
|
"notification_profile_schedule",
|
||||||
|
"notification_profile_allowed_members",
|
||||||
|
"story_sends"
|
||||||
|
};
|
||||||
|
|
||||||
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret,
|
||||||
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
|
@NonNull SQLiteDatabase db, @NonNull Uri uri, @NonNull String passphrase)
|
||||||
throws IOException
|
throws IOException
|
||||||
@@ -85,12 +97,12 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
int count = 0;
|
int count = 0;
|
||||||
|
|
||||||
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
|
SQLiteDatabase keyValueDatabase = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()).getSqlCipherDatabase();
|
||||||
|
|
||||||
|
db.beginTransaction();
|
||||||
|
keyValueDatabase.beginTransaction();
|
||||||
try {
|
try {
|
||||||
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
|
BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase);
|
||||||
|
|
||||||
db.beginTransaction();
|
|
||||||
keyValueDatabase.beginTransaction();
|
|
||||||
|
|
||||||
dropAllTables(db);
|
dropAllTables(db);
|
||||||
|
|
||||||
BackupFrame frame;
|
BackupFrame frame;
|
||||||
@@ -272,12 +284,17 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static void dropAllTables(@NonNull SQLiteDatabase db) {
|
private static void dropAllTables(@NonNull SQLiteDatabase db) {
|
||||||
|
for (String name : TABLES_TO_DROP_FIRST) {
|
||||||
|
db.execSQL("DROP TABLE IF EXISTS " + name);
|
||||||
|
}
|
||||||
|
|
||||||
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
|
try (Cursor cursor = db.rawQuery("SELECT name, type FROM sqlite_master", null)) {
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
String name = cursor.getString(0);
|
String name = cursor.getString(0);
|
||||||
String type = cursor.getString(1);
|
String type = cursor.getString(1);
|
||||||
|
|
||||||
if ("table".equals(type) && !name.startsWith("sqlite_")) {
|
if ("table".equals(type) && !name.startsWith("sqlite_")) {
|
||||||
|
Log.i(TAG, "Dropping table: " + name);
|
||||||
db.execSQL("DROP TABLE IF EXISTS " + name);
|
db.execSQL("DROP TABLE IF EXISTS " + name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
|||||||
import org.thoughtcrime.securesms.R
|
import org.thoughtcrime.securesms.R
|
||||||
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||||
|
import org.thoughtcrime.securesms.glide.GiftBadgeModel
|
||||||
import org.thoughtcrime.securesms.mms.GlideApp
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
import org.thoughtcrime.securesms.mms.GlideRequests
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||||
import java.lang.IllegalArgumentException
|
|
||||||
|
|
||||||
class BadgeImageView @JvmOverloads constructor(
|
class BadgeImageView @JvmOverloads constructor(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -78,6 +80,20 @@ class BadgeImageView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setGiftBadge(badge: GiftBadge?, glideRequests: GlideRequests) {
|
||||||
|
if (badge != null) {
|
||||||
|
glideRequests
|
||||||
|
.load(GiftBadgeModel(badge))
|
||||||
|
.downsample(DownsampleStrategy.NONE)
|
||||||
|
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), ScreenDensity.getBestDensityBucketForDevice(), ThemeUtil.isDarkTheme(context)))
|
||||||
|
.into(this)
|
||||||
|
} else {
|
||||||
|
glideRequests
|
||||||
|
.clear(this)
|
||||||
|
clearDrawable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearDrawable() {
|
private fun clearDrawable() {
|
||||||
setImageDrawable(null)
|
setImageDrawable(null)
|
||||||
isClickable = false
|
isClickable = false
|
||||||
|
|||||||
@@ -1,41 +1,83 @@
|
|||||||
package org.thoughtcrime.securesms.badges
|
package org.thoughtcrime.securesms.badges
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.annotation.WorkerThread
|
||||||
import io.reactivex.rxjava3.core.Completable
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
import org.thoughtcrime.securesms.database.RecipientDatabase
|
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
|
||||||
|
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
|
||||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||||
import org.thoughtcrime.securesms.util.ProfileUtil
|
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
class BadgeRepository(context: Context) {
|
class BadgeRepository(context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(BadgeRepository::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
private val context = context.applicationContext
|
private val context = context.applicationContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the visibility for each badge on a user's profile, and uploads them to the server.
|
||||||
|
* Does not write to the local database. The caller must either do that themselves or schedule
|
||||||
|
* a refresh own profile job.
|
||||||
|
*
|
||||||
|
* @return A list of the badges, properly modified to either visible or not visible, according to user preferences.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
@WorkerThread
|
||||||
|
fun setVisibilityForAllBadgesSync(
|
||||||
|
displayBadgesOnProfile: Boolean,
|
||||||
|
selfBadges: List<Badge>
|
||||||
|
): List<Badge> {
|
||||||
|
Log.d(TAG, "[setVisibilityForAllBadgesSync] Setting badge visibility...", true)
|
||||||
|
|
||||||
|
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
|
||||||
|
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
|
||||||
|
|
||||||
|
Log.d(TAG, "[setVisibilityForAllBadgesSync] Uploading profile...", true)
|
||||||
|
ProfileUtil.uploadProfileWithBadges(context, badges)
|
||||||
|
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
|
||||||
|
recipientDatabase.markNeedsSync(Recipient.self().id)
|
||||||
|
|
||||||
|
Log.d(TAG, "[setVisibilityForAllBadgesSync] Requesting data change sync...", true)
|
||||||
|
StorageSyncHelper.scheduleSyncForDataChange()
|
||||||
|
|
||||||
|
return badges
|
||||||
|
}
|
||||||
|
|
||||||
fun setVisibilityForAllBadges(
|
fun setVisibilityForAllBadges(
|
||||||
displayBadgesOnProfile: Boolean,
|
displayBadgesOnProfile: Boolean,
|
||||||
selfBadges: List<Badge> = Recipient.self().badges
|
selfBadges: List<Badge> = Recipient.self().badges
|
||||||
): Completable = Completable.fromAction {
|
): Completable = Completable.fromAction {
|
||||||
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
|
setVisibilityForAllBadgesSync(displayBadgesOnProfile, selfBadges)
|
||||||
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
|
|
||||||
|
|
||||||
ProfileUtil.uploadProfileWithBadges(context, badges)
|
Log.d(TAG, "[setVisibilityForAllBadges] Enqueueing profile refresh...", true)
|
||||||
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
|
ApplicationDependencies.getJobManager()
|
||||||
recipientDatabase.markNeedsSync(Recipient.self().id)
|
.startChain(RefreshOwnProfileJob())
|
||||||
StorageSyncHelper.scheduleSyncForDataChange()
|
.then(MultiDeviceProfileContentUpdateJob())
|
||||||
|
.enqueue()
|
||||||
recipientDatabase.setBadges(Recipient.self().id, badges)
|
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
|
|
||||||
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
||||||
val badges = Recipient.self().badges
|
val badges = Recipient.self().badges
|
||||||
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
|
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
|
||||||
|
|
||||||
|
Log.d(TAG, "[setFeaturedBadge] Uploading profile with reordered badges...", true)
|
||||||
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
||||||
|
|
||||||
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
|
Log.d(TAG, "[setFeaturedBadge] Enqueueing profile refresh...", true)
|
||||||
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
|
ApplicationDependencies.getJobManager()
|
||||||
|
.startChain(RefreshOwnProfileJob())
|
||||||
|
.then(MultiDeviceProfileContentUpdateJob())
|
||||||
|
.enqueue()
|
||||||
}.subscribeOn(Schedulers.io())
|
}.subscribeOn(Schedulers.io())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,17 @@ import com.google.android.flexbox.FlexboxLayoutManager
|
|||||||
import com.google.android.flexbox.JustifyContent
|
import com.google.android.flexbox.JustifyContent
|
||||||
import org.signal.core.util.DimensionUnit
|
import org.signal.core.util.DimensionUnit
|
||||||
import org.signal.core.util.logging.Log
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.signal.libsignal.protocol.util.Pair
|
||||||
import org.thoughtcrime.securesms.BuildConfig
|
import org.thoughtcrime.securesms.BuildConfig
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
|
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
|
||||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
||||||
import org.thoughtcrime.securesms.util.ScreenDensity
|
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||||
import org.whispersystems.libsignal.util.Pair
|
|
||||||
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.sql.Timestamp
|
import java.sql.Timestamp
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
object Badges {
|
object Badges {
|
||||||
|
|
||||||
@@ -93,7 +94,8 @@ object Badges {
|
|||||||
Uri.parse(badge.imageUrl),
|
Uri.parse(badge.imageUrl),
|
||||||
badge.imageDensity,
|
badge.imageDensity,
|
||||||
badge.expiration,
|
badge.expiration,
|
||||||
badge.visible
|
badge.visible,
|
||||||
|
0L
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +124,8 @@ object Badges {
|
|||||||
uriAndDensity.first(),
|
uriAndDensity.first(),
|
||||||
uriAndDensity.second(),
|
uriAndDensity.second(),
|
||||||
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
|
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
|
||||||
serviceBadge.isVisible
|
serviceBadge.isVisible,
|
||||||
|
TimeUnit.SECONDS.toMillis(serviceBadge.duration)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import org.thoughtcrime.securesms.badges.gifts.ExpiredGiftSheetConfiguration.forExpiredBadge
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||||
|
import org.thoughtcrime.securesms.components.settings.configure
|
||||||
|
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||||
|
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays expired gift information and gives the user the option to start a recurring monthly donation.
|
||||||
|
*/
|
||||||
|
class ExpiredGiftSheet : DSLSettingsBottomSheetFragment() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_BADGE = "arg.badge"
|
||||||
|
|
||||||
|
fun show(fragmentManager: FragmentManager, badge: Badge) {
|
||||||
|
ExpiredGiftSheet().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putParcelable(ARG_BADGE, badge)
|
||||||
|
}
|
||||||
|
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val badge: Badge
|
||||||
|
get() = requireArguments().getParcelable(ARG_BADGE)!!
|
||||||
|
|
||||||
|
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||||
|
ExpiredGiftSheetConfiguration.register(adapter)
|
||||||
|
adapter.submitList(
|
||||||
|
configure {
|
||||||
|
forExpiredBadge(
|
||||||
|
badge = badge,
|
||||||
|
onMakeAMonthlyDonation = {
|
||||||
|
requireListener<Callback>().onMakeAMonthlyDonation()
|
||||||
|
},
|
||||||
|
onNotNow = {
|
||||||
|
dismissAllowingStateLoss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toMappingModelList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback {
|
||||||
|
fun onMakeAMonthlyDonation()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.badges.models.BadgeDisplay112
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contains shared DSL layout for expired gifts, creatable using a GiftBadge or a Badge.
|
||||||
|
*/
|
||||||
|
object ExpiredGiftSheetConfiguration {
|
||||||
|
fun register(mappingAdapter: MappingAdapter) {
|
||||||
|
BadgeDisplay112.register(mappingAdapter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DSLConfiguration.forExpiredBadge(badge: Badge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||||
|
customPref(BadgeDisplay112.Model(badge, withDisplayText = false))
|
||||||
|
expiredSheet(onMakeAMonthlyDonation, onNotNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun DSLConfiguration.forExpiredGiftBadge(giftBadge: GiftBadge, onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||||
|
customPref(BadgeDisplay112.GiftModel(giftBadge))
|
||||||
|
expiredSheet(onMakeAMonthlyDonation, onNotNow)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DSLConfiguration.expiredSheet(onMakeAMonthlyDonation: () -> Unit, onNotNow: () -> Unit) {
|
||||||
|
textPref(
|
||||||
|
title = DSLSettingsText.from(
|
||||||
|
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired,
|
||||||
|
DSLSettingsText.CenterModifier, DSLSettingsText.TitleLargeModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
textPref(
|
||||||
|
title = DSLSettingsText.from(
|
||||||
|
stringId = R.string.ExpiredGiftSheetConfiguration__your_gift_badge_has_expired_and_is,
|
||||||
|
DSLSettingsText.CenterModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (SignalStore.donationsValues().isLikelyASustainer()) {
|
||||||
|
primaryButton(
|
||||||
|
text = DSLSettingsText.from(
|
||||||
|
stringId = android.R.string.ok
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
onNotNow()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
textPref(
|
||||||
|
title = DSLSettingsText.from(
|
||||||
|
stringId = R.string.ExpiredGiftSheetConfiguration__to_continue,
|
||||||
|
DSLSettingsText.CenterModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
primaryButton(
|
||||||
|
text = DSLSettingsText.from(
|
||||||
|
stringId = R.string.ExpiredGiftSheetConfiguration__make_a_monthly_donation
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
onMakeAMonthlyDonation()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
secondaryButtonNoOutline(
|
||||||
|
text = DSLSettingsText.from(
|
||||||
|
stringId = R.string.ExpiredGiftSheetConfiguration__not_now
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
onNotNow()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.res.use
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import org.signal.core.util.DimensionUnit
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
|
import org.thoughtcrime.securesms.badges.gifts.Gifts.formatExpiry
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a gift badge sent to or received from a user, and allows the user to
|
||||||
|
* perform an action based off the badge's redemption state.
|
||||||
|
*/
|
||||||
|
class GiftMessageView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : FrameLayout(context, attrs) {
|
||||||
|
init {
|
||||||
|
inflate(context, R.layout.gift_message_view, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val badgeView: BadgeImageView = findViewById(R.id.gift_message_view_badge)
|
||||||
|
private val titleView: TextView = findViewById(R.id.gift_message_view_title)
|
||||||
|
private val descriptionView: TextView = findViewById(R.id.gift_message_view_description)
|
||||||
|
private val actionView: MaterialButton = findViewById(R.id.gift_message_view_action)
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.obtainStyledAttributes(attrs, R.styleable.GiftMessageView).use {
|
||||||
|
val textColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__textColor, Color.RED)
|
||||||
|
titleView.setTextColor(textColor)
|
||||||
|
descriptionView.setTextColor(textColor)
|
||||||
|
|
||||||
|
val buttonTextColor = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonTextColor, Color.RED)
|
||||||
|
actionView.setTextColor(buttonTextColor)
|
||||||
|
actionView.iconTint = ColorStateList.valueOf(buttonTextColor)
|
||||||
|
|
||||||
|
val buttonBackgroundTint = it.getColor(R.styleable.GiftMessageView_giftMessageView__buttonBackgroundTint, Color.RED)
|
||||||
|
actionView.backgroundTintList = ColorStateList.valueOf(buttonBackgroundTint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setGiftBadge(glideRequests: GlideRequests, giftBadge: GiftBadge, isOutgoing: Boolean, callback: Callback) {
|
||||||
|
titleView.setText(R.string.GiftMessageView__gift_badge)
|
||||||
|
descriptionView.text = giftBadge.formatExpiry(context)
|
||||||
|
actionView.icon = null
|
||||||
|
actionView.setOnClickListener { callback.onViewGiftBadgeClicked() }
|
||||||
|
actionView.isEnabled = true
|
||||||
|
|
||||||
|
if (isOutgoing) {
|
||||||
|
actionView.setText(R.string.GiftMessageView__view)
|
||||||
|
} else {
|
||||||
|
when (giftBadge.redemptionState) {
|
||||||
|
GiftBadge.RedemptionState.REDEEMED -> {
|
||||||
|
stopAnimationIfNeeded()
|
||||||
|
actionView.setIconResource(R.drawable.ic_check_circle_24)
|
||||||
|
}
|
||||||
|
GiftBadge.RedemptionState.STARTED -> actionView.icon = CircularProgressDrawable(context).apply {
|
||||||
|
actionView.isEnabled = false
|
||||||
|
setColorSchemeColors(ContextCompat.getColor(context, R.color.core_ultramarine))
|
||||||
|
strokeWidth = DimensionUnit.DP.toPixels(2f)
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
stopAnimationIfNeeded()
|
||||||
|
actionView.icon = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actionView.setText(
|
||||||
|
when (giftBadge.redemptionState ?: GiftBadge.RedemptionState.UNRECOGNIZED) {
|
||||||
|
GiftBadge.RedemptionState.PENDING -> R.string.GiftMessageView__redeem
|
||||||
|
GiftBadge.RedemptionState.STARTED -> R.string.GiftMessageView__redeeming
|
||||||
|
GiftBadge.RedemptionState.REDEEMED -> R.string.GiftMessageView__redeemed
|
||||||
|
GiftBadge.RedemptionState.FAILED -> R.string.GiftMessageView__redeem
|
||||||
|
GiftBadge.RedemptionState.UNRECOGNIZED -> R.string.GiftMessageView__redeem
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeView.setGiftBadge(giftBadge, glideRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onGiftNotOpened() {
|
||||||
|
actionView.isClickable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onGiftOpened() {
|
||||||
|
actionView.isClickable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopAnimationIfNeeded() {
|
||||||
|
val icon = actionView.icon
|
||||||
|
if (icon is CircularProgressDrawable) {
|
||||||
|
icon.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Callback {
|
||||||
|
fun onViewGiftBadgeClicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.signal.libsignal.zkgroup.InvalidInputException
|
||||||
|
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.database.ThreadDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.model.StoryType
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||||
|
import org.thoughtcrime.securesms.mms.OutgoingMediaMessage
|
||||||
|
import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.Base64
|
||||||
|
import java.lang.Integer.min
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper object for Gift badges
|
||||||
|
*/
|
||||||
|
object Gifts {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request Code for getting token from Google Pay
|
||||||
|
*/
|
||||||
|
const val GOOGLE_PAY_REQUEST_CODE = 3000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an OutgoingSecureMediaMessage which contains the given gift badge.
|
||||||
|
*/
|
||||||
|
fun createOutgoingGiftMessage(
|
||||||
|
recipient: Recipient,
|
||||||
|
giftBadge: GiftBadge,
|
||||||
|
sentTimestamp: Long,
|
||||||
|
expiresIn: Long
|
||||||
|
): OutgoingMediaMessage {
|
||||||
|
return OutgoingSecureMediaMessage(
|
||||||
|
recipient,
|
||||||
|
Base64.encodeBytes(giftBadge.toByteArray()),
|
||||||
|
listOf(),
|
||||||
|
sentTimestamp,
|
||||||
|
ThreadDatabase.DistributionTypes.CONVERSATION,
|
||||||
|
expiresIn,
|
||||||
|
false,
|
||||||
|
StoryType.NONE,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
listOf(),
|
||||||
|
listOf(),
|
||||||
|
listOf(),
|
||||||
|
giftBadge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the expiration time from the redemption token, in UNIX epoch seconds.
|
||||||
|
*/
|
||||||
|
private fun GiftBadge.getExpiry(): Long {
|
||||||
|
return try {
|
||||||
|
ReceiptCredentialPresentation(redemptionToken.toByteArray()).receiptExpirationTime
|
||||||
|
} catch (e: InvalidInputException) {
|
||||||
|
return 0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun GiftBadge.formatExpiry(context: Context): String {
|
||||||
|
val expiry = getExpiry()
|
||||||
|
val timeRemaining = TimeUnit.SECONDS.toMillis(expiry) - System.currentTimeMillis()
|
||||||
|
if (timeRemaining <= 0) {
|
||||||
|
return context.getString(R.string.Gifts__expired)
|
||||||
|
}
|
||||||
|
|
||||||
|
val days = TimeUnit.MILLISECONDS.toDays(timeRemaining).toInt()
|
||||||
|
if (days > 0) {
|
||||||
|
return context.resources.getQuantityString(R.plurals.Gifts__d_days_remaining, days, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hours = TimeUnit.MILLISECONDS.toHours(timeRemaining).toInt()
|
||||||
|
if (hours > 0) {
|
||||||
|
return context.resources.getQuantityString(R.plurals.Gifts__d_hours_remaining, hours, hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minutes = min(1, TimeUnit.MILLISECONDS.toMinutes(timeRemaining).toInt())
|
||||||
|
return context.resources.getQuantityString(R.plurals.Gifts__d_minutes_remaining, minutes, minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.util.Projection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes that a given item can have a gift box drawn over it.
|
||||||
|
*/
|
||||||
|
interface OpenableGift {
|
||||||
|
/**
|
||||||
|
* Returns a projection to draw a top, or null to not do so.
|
||||||
|
*/
|
||||||
|
fun getOpenableGiftProjection(isAnimating: Boolean): Projection?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a unique id assosicated with this gift.
|
||||||
|
*/
|
||||||
|
fun getGiftId(): Long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a callback to start the open animation
|
||||||
|
*/
|
||||||
|
fun setOpenGiftCallback(openGift: (OpenableGift) -> Unit)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears any callback created to start the open animation
|
||||||
|
*/
|
||||||
|
fun clearOpenGiftCallback()
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts
|
||||||
|
|
||||||
|
import android.animation.FloatEvaluator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
|
import android.view.animation.AnticipateInterpolator
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.toRect
|
||||||
|
import androidx.core.graphics.withSave
|
||||||
|
import androidx.core.graphics.withTranslation
|
||||||
|
import androidx.core.view.children
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.signal.core.util.DimensionUnit
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.util.Projection
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls the gift box top and related animations for Gift bubbles.
|
||||||
|
*/
|
||||||
|
class OpenableGiftItemDecoration(context: Context) : RecyclerView.ItemDecoration(), DefaultLifecycleObserver {
|
||||||
|
|
||||||
|
private val animatorDurationScale = Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1.0f)
|
||||||
|
private val messageIdsShakenThisSession = mutableSetOf<Long>()
|
||||||
|
private val messageIdsOpenedThisSession = mutableSetOf<Long>()
|
||||||
|
private val animationState = mutableMapOf<Long, GiftAnimationState>()
|
||||||
|
|
||||||
|
private val rect = RectF()
|
||||||
|
private val lineWidth = DimensionUnit.DP.toPixels(16f).toInt()
|
||||||
|
|
||||||
|
private val boxPaint = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = ContextCompat.getColor(context, R.color.core_ultramarine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bowPaint = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
color = Color.WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bowWidth = DimensionUnit.DP.toPixels(80f)
|
||||||
|
private val bowHeight = DimensionUnit.DP.toPixels(60f)
|
||||||
|
private val bowDrawable: Drawable = AppCompatResources.getDrawable(context, R.drawable.ic_gift_bow)!!
|
||||||
|
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) {
|
||||||
|
super.onDestroy(owner)
|
||||||
|
animationState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrawOver(c: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||||
|
var needsInvalidation = false
|
||||||
|
val openableChildren = parent.children.filterIsInstance(OpenableGift::class.java)
|
||||||
|
|
||||||
|
val deadKeys = animationState.keys.filterNot { giftId -> openableChildren.any { it.getGiftId() == giftId } }
|
||||||
|
deadKeys.forEach {
|
||||||
|
animationState.remove(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
val notAnimated = openableChildren.filterNot { animationState.containsKey(it.getGiftId()) }
|
||||||
|
|
||||||
|
notAnimated.filterNot { messageIdsOpenedThisSession.contains(it.getGiftId()) }.forEach { child ->
|
||||||
|
val projection = child.getOpenableGiftProjection(false)
|
||||||
|
if (projection != null) {
|
||||||
|
child.setOpenGiftCallback {
|
||||||
|
child.clearOpenGiftCallback()
|
||||||
|
val proj = it.getOpenableGiftProjection(true)
|
||||||
|
if (proj != null) {
|
||||||
|
messageIdsOpenedThisSession.add(it.getGiftId())
|
||||||
|
startOpenAnimation(it)
|
||||||
|
parent.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messageIdsShakenThisSession.contains(child.getGiftId())) {
|
||||||
|
drawGiftBox(c, projection)
|
||||||
|
drawGiftBow(c, projection)
|
||||||
|
} else {
|
||||||
|
messageIdsShakenThisSession.add(child.getGiftId())
|
||||||
|
startShakeAnimation(child)
|
||||||
|
|
||||||
|
drawGiftBox(c, projection)
|
||||||
|
drawGiftBow(c, projection)
|
||||||
|
|
||||||
|
needsInvalidation = true
|
||||||
|
}
|
||||||
|
|
||||||
|
projection.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openableChildren.filter { animationState.containsKey(it.getGiftId()) }.forEach { child ->
|
||||||
|
val runningAnimation = animationState[child.getGiftId()]!!
|
||||||
|
c.withSave {
|
||||||
|
val isThisAnimationRunning = runningAnimation.update(
|
||||||
|
animatorDurationScale = animatorDurationScale,
|
||||||
|
canvas = c,
|
||||||
|
drawBox = this@OpenableGiftItemDecoration::drawGiftBox,
|
||||||
|
drawBow = this@OpenableGiftItemDecoration::drawGiftBow
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isThisAnimationRunning) {
|
||||||
|
animationState.remove(child.getGiftId())
|
||||||
|
}
|
||||||
|
|
||||||
|
needsInvalidation = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsInvalidation) {
|
||||||
|
parent.invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawGiftBox(canvas: Canvas, projection: Projection) {
|
||||||
|
canvas.drawPath(projection.path, boxPaint)
|
||||||
|
|
||||||
|
rect.set(
|
||||||
|
projection.x + (projection.width / 2) - lineWidth / 2,
|
||||||
|
projection.y,
|
||||||
|
projection.x + (projection.width / 2) + lineWidth / 2,
|
||||||
|
projection.y + projection.height
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.drawRect(rect, bowPaint)
|
||||||
|
|
||||||
|
rect.set(
|
||||||
|
projection.x,
|
||||||
|
projection.y + (projection.height / 2) - lineWidth / 2,
|
||||||
|
projection.x + projection.width,
|
||||||
|
projection.y + (projection.height / 2) + lineWidth / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
canvas.drawRect(rect, bowPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawGiftBow(canvas: Canvas, projection: Projection) {
|
||||||
|
rect.set(
|
||||||
|
projection.x + (projection.width / 2) - (bowWidth / 2),
|
||||||
|
projection.y,
|
||||||
|
projection.x + (projection.width / 2) + (bowWidth / 2),
|
||||||
|
projection.y + bowHeight
|
||||||
|
)
|
||||||
|
|
||||||
|
val padTop = (projection.height - rect.height()) * (48f / 89f)
|
||||||
|
|
||||||
|
bowDrawable.bounds = rect.toRect()
|
||||||
|
canvas.withTranslation(y = padTop) {
|
||||||
|
bowDrawable.draw(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startShakeAnimation(child: OpenableGift) {
|
||||||
|
animationState[child.getGiftId()] = GiftAnimationState.ShakeAnimationState(child, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startOpenAnimation(child: OpenableGift) {
|
||||||
|
animationState[child.getGiftId()] = GiftAnimationState.OpenAnimationState(child, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class GiftAnimationState(val openableGift: OpenableGift, val startTime: Long, val duration: Long) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shakes the gift box to the left and right, slightly revealing the contents underneath.
|
||||||
|
* Uses a lag value to keep the bow one "frame" behind the box, to give it the effect of
|
||||||
|
* following behind.
|
||||||
|
*/
|
||||||
|
class ShakeAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, SHAKE_DURATION_MILLIS) {
|
||||||
|
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
|
||||||
|
canvas.withTranslation(x = getTranslation(progress).toFloat()) {
|
||||||
|
drawBox(canvas, projection)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.withTranslation(x = getTranslation(lastFrameProgress).toFloat()) {
|
||||||
|
drawBow(canvas, projection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTranslation(progress: Float): Double {
|
||||||
|
val interpolated = INTERPOLATOR.getInterpolation(progress)
|
||||||
|
val evaluated = EVALUATOR.evaluate(interpolated, 0f, 360f)
|
||||||
|
|
||||||
|
return 0.25f * sin(4 * evaluated * PI / 180f) * 180f / PI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OpenAnimationState(openableGift: OpenableGift, startTime: Long) : GiftAnimationState(openableGift, startTime, OPEN_DURATION_MILLIS) {
|
||||||
|
override fun update(canvas: Canvas, projection: Projection, progress: Float, lastFrameProgress: Float, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit) {
|
||||||
|
val interpolatedProgress = INTERPOLATOR.getInterpolation(progress)
|
||||||
|
val evaluatedValue = EVALUATOR.evaluate(interpolatedProgress, 0f, DimensionUnit.DP.toPixels(161f))
|
||||||
|
|
||||||
|
val interpolatedY = TRANSLATION_Y_INTERPOLATOR.getInterpolation(progress)
|
||||||
|
val evaluatedY = EVALUATOR.evaluate(interpolatedY, 0f, DimensionUnit.DP.toPixels(355f))
|
||||||
|
|
||||||
|
canvas.translate(evaluatedValue, evaluatedY)
|
||||||
|
|
||||||
|
drawBox(canvas, projection)
|
||||||
|
drawBow(canvas, projection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(animatorDurationScale: Float, canvas: Canvas, drawBox: (Canvas, Projection) -> Unit, drawBow: (Canvas, Projection) -> Unit): Boolean {
|
||||||
|
val projection = openableGift.getOpenableGiftProjection(true) ?: return false
|
||||||
|
|
||||||
|
if (animatorDurationScale <= 0f) {
|
||||||
|
update(canvas, projection, 0f, 0f, drawBox, drawBow)
|
||||||
|
projection.release()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentFrameTime = System.currentTimeMillis()
|
||||||
|
val lastFrameProgress = max(0f, (currentFrameTime - startTime - ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS) / (duration.toFloat() * animatorDurationScale))
|
||||||
|
val progress = (currentFrameTime - startTime) / (duration.toFloat() * animatorDurationScale)
|
||||||
|
|
||||||
|
if (progress > 1f) {
|
||||||
|
update(canvas, projection, 1f, 1f, drawBox, drawBow)
|
||||||
|
projection.release()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
update(canvas, projection, progress, lastFrameProgress, drawBox, drawBow)
|
||||||
|
projection.release()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract fun update(
|
||||||
|
canvas: Canvas,
|
||||||
|
projection: Projection,
|
||||||
|
progress: Float,
|
||||||
|
lastFrameProgress: Float,
|
||||||
|
drawBox: (Canvas, Projection) -> Unit,
|
||||||
|
drawBow: (Canvas, Projection) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TRANSLATION_Y_INTERPOLATOR = AnticipateInterpolator(3f)
|
||||||
|
private val INTERPOLATOR = AccelerateDecelerateInterpolator()
|
||||||
|
private val EVALUATOR = FloatEvaluator()
|
||||||
|
|
||||||
|
private const val SHAKE_DURATION_MILLIS = 1000L
|
||||||
|
private const val OPEN_DURATION_MILLIS = 700L
|
||||||
|
private const val ONE_FRAME_RELATIVE_TO_30_FPS_MILLIS = 33
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import org.signal.core.util.money.FiatMoney
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience wrapper for a gift at a particular price point.
|
||||||
|
*/
|
||||||
|
data class Gift(val level: Long, val price: FiatMoney)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.navigation.findNavController
|
||||||
|
import androidx.navigation.fragment.NavHostFragment
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import io.reactivex.rxjava3.subjects.Subject
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity which houses the gift flow.
|
||||||
|
*/
|
||||||
|
class GiftFlowActivity : FragmentWrapperActivity(), DonationPaymentComponent {
|
||||||
|
|
||||||
|
override val donationPaymentRepository: DonationPaymentRepository by lazy { DonationPaymentRepository(this) }
|
||||||
|
|
||||||
|
override val googlePayResultPublisher: Subject<DonationPaymentComponent.GooglePayResult> = PublishSubject.create()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||||
|
super.onCreate(savedInstanceState, ready)
|
||||||
|
onBackPressedDispatcher.addCallback(this, OnBackPressed())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFragment(): Fragment {
|
||||||
|
return NavHostFragment.create(R.navigation.gift_flow)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
googlePayResultPublisher.onNext(DonationPaymentComponent.GooglePayResult(requestCode, resultCode, data))
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class OnBackPressed : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (!findNavController(R.id.fragment_container).popBackStack()) {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.MainActivity
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.InputAwareLayout
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationEvent
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationError
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorDialogs
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.DonationErrorSource
|
||||||
|
import org.thoughtcrime.securesms.components.settings.configure
|
||||||
|
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||||
|
import org.thoughtcrime.securesms.components.settings.models.TextInput
|
||||||
|
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||||
|
import org.thoughtcrime.securesms.keyboard.KeyboardPage
|
||||||
|
import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel
|
||||||
|
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||||
|
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
|
||||||
|
import org.thoughtcrime.securesms.util.Debouncer
|
||||||
|
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||||
|
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the user to confirm details about a gift, add a message, and finally make a payment.
|
||||||
|
*/
|
||||||
|
class GiftFlowConfirmationFragment :
|
||||||
|
DSLSettingsFragment(
|
||||||
|
titleId = R.string.GiftFlowConfirmationFragment__confirm_gift,
|
||||||
|
layoutId = R.layout.gift_flow_confirmation_fragment
|
||||||
|
),
|
||||||
|
EmojiKeyboardPageFragment.Callback,
|
||||||
|
EmojiEventListener,
|
||||||
|
EmojiSearchFragment.Callback {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val TAG = Log.tag(GiftFlowConfirmationFragment::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: GiftFlowViewModel by viewModels(
|
||||||
|
ownerProducer = { requireActivity() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private val keyboardPagerViewModel: KeyboardPagerViewModel by viewModels(
|
||||||
|
ownerProducer = { requireActivity() }
|
||||||
|
)
|
||||||
|
|
||||||
|
private lateinit var inputAwareLayout: InputAwareLayout
|
||||||
|
private lateinit var emojiKeyboard: MediaKeyboard
|
||||||
|
|
||||||
|
private val lifecycleDisposable = LifecycleDisposable()
|
||||||
|
private var errorDialog: DialogInterface? = null
|
||||||
|
private lateinit var processingDonationPaymentDialog: AlertDialog
|
||||||
|
private lateinit var verifyingRecipientDonationPaymentDialog: AlertDialog
|
||||||
|
private lateinit var donationPaymentComponent: DonationPaymentComponent
|
||||||
|
private lateinit var textInputViewHolder: TextInput.MultilineViewHolder
|
||||||
|
|
||||||
|
private val eventPublisher = PublishSubject.create<TextInput.TextInputEvent>()
|
||||||
|
private val debouncer = Debouncer(100L)
|
||||||
|
|
||||||
|
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||||
|
RecipientPreference.register(adapter)
|
||||||
|
GiftRowItem.register(adapter)
|
||||||
|
|
||||||
|
keyboardPagerViewModel.setOnlyPage(KeyboardPage.EMOJI)
|
||||||
|
|
||||||
|
donationPaymentComponent = requireListener()
|
||||||
|
|
||||||
|
processingDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setView(R.layout.processing_payment_dialog)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
verifyingRecipientDonationPaymentDialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setView(R.layout.verifying_recipient_payment_dialog)
|
||||||
|
.setCancelable(false)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
inputAwareLayout = requireView().findViewById(R.id.input_aware_layout)
|
||||||
|
emojiKeyboard = requireView().findViewById(R.id.emoji_drawer)
|
||||||
|
|
||||||
|
emojiKeyboard.setFragmentManager(childFragmentManager)
|
||||||
|
|
||||||
|
val googlePayButton = requireView().findViewById<GooglePayButton>(R.id.google_pay_button)
|
||||||
|
googlePayButton.setOnGooglePayClickListener {
|
||||||
|
viewModel.requestTokenFromGooglePay(getString(R.string.preferences__one_time))
|
||||||
|
}
|
||||||
|
|
||||||
|
val textInput = requireView().findViewById<FrameLayout>(R.id.text_input)
|
||||||
|
val emojiToggle = textInput.findViewById<ImageView>(R.id.emoji_toggle)
|
||||||
|
textInputViewHolder = TextInput.MultilineViewHolder(textInput, eventPublisher)
|
||||||
|
textInputViewHolder.onAttachedToWindow()
|
||||||
|
|
||||||
|
inputAwareLayout.addOnKeyboardShownListener {
|
||||||
|
if (emojiKeyboard.isEmojiSearchMode) {
|
||||||
|
return@addOnKeyboardShownListener
|
||||||
|
}
|
||||||
|
|
||||||
|
inputAwareLayout.hideAttachedInput(true)
|
||||||
|
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||||
|
}
|
||||||
|
|
||||||
|
requireActivity().onBackPressedDispatcher.addCallback(
|
||||||
|
viewLifecycleOwner,
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
if (inputAwareLayout.isInputOpen) {
|
||||||
|
inputAwareLayout.hideAttachedInput(true)
|
||||||
|
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||||
|
} else {
|
||||||
|
findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
textInputViewHolder.bind(
|
||||||
|
TextInput.MultilineModel(
|
||||||
|
text = viewModel.snapshot.additionalMessage,
|
||||||
|
hint = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__add_a_message),
|
||||||
|
onTextChanged = {
|
||||||
|
viewModel.setAdditionalMessage(it)
|
||||||
|
},
|
||||||
|
onEmojiToggleClicked = {
|
||||||
|
if (inputAwareLayout.isKeyboardOpen || (!inputAwareLayout.isKeyboardOpen && !inputAwareLayout.isInputOpen)) {
|
||||||
|
inputAwareLayout.show(it, emojiKeyboard)
|
||||||
|
emojiToggle.setImageResource(R.drawable.ic_keyboard_24)
|
||||||
|
} else {
|
||||||
|
inputAwareLayout.showSoftkey(it)
|
||||||
|
emojiToggle.setImageResource(R.drawable.ic_emoji_smiley_24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||||
|
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||||
|
|
||||||
|
if (state.stage == GiftFlowState.Stage.RECIPIENT_VERIFICATION) {
|
||||||
|
debouncer.publish { verifyingRecipientDonationPaymentDialog.show() }
|
||||||
|
} else {
|
||||||
|
debouncer.clear()
|
||||||
|
verifyingRecipientDonationPaymentDialog.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.stage == GiftFlowState.Stage.PAYMENT_PIPELINE) {
|
||||||
|
processingDonationPaymentDialog.show()
|
||||||
|
} else {
|
||||||
|
processingDonationPaymentDialog.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||||
|
|
||||||
|
lifecycleDisposable += DonationError
|
||||||
|
.getErrorsForSource(DonationErrorSource.GIFT)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { donationError ->
|
||||||
|
onPaymentError(donationError)
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleDisposable += viewModel.events.observeOn(AndroidSchedulers.mainThread()).subscribe { donationEvent ->
|
||||||
|
when (donationEvent) {
|
||||||
|
is DonationEvent.PaymentConfirmationSuccess -> onPaymentConfirmed()
|
||||||
|
DonationEvent.RequestTokenSuccess -> Log.i(TAG, "Successfully got request token from Google Pay")
|
||||||
|
DonationEvent.SubscriptionCancelled -> Unit
|
||||||
|
is DonationEvent.SubscriptionCancellationFailed -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleDisposable += donationPaymentComponent.googlePayResultPublisher.subscribe {
|
||||||
|
viewModel.onActivityResult(it.requestCode, it.resultCode, it.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
textInputViewHolder.onDetachedFromWindow()
|
||||||
|
processingDonationPaymentDialog.dismiss()
|
||||||
|
debouncer.clear()
|
||||||
|
verifyingRecipientDonationPaymentDialog.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getConfiguration(giftFlowState: GiftFlowState): DSLConfiguration {
|
||||||
|
return configure {
|
||||||
|
if (giftFlowState.giftBadge != null) {
|
||||||
|
giftFlowState.giftPrices[giftFlowState.currency]?.let {
|
||||||
|
customPref(
|
||||||
|
GiftRowItem.Model(
|
||||||
|
giftBadge = giftFlowState.giftBadge,
|
||||||
|
price = it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionHeaderPref(R.string.GiftFlowConfirmationFragment__send_to)
|
||||||
|
|
||||||
|
customPref(
|
||||||
|
RecipientPreference.Model(
|
||||||
|
recipient = giftFlowState.recipient!!
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
textPref(
|
||||||
|
summary = DSLSettingsText.from(R.string.GiftFlowConfirmationFragment__your_gift_will_be_sent_in)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPaymentConfirmed() {
|
||||||
|
val mainActivityIntent = MainActivity.clearTop(requireContext())
|
||||||
|
val conversationIntent = ConversationIntents
|
||||||
|
.createBuilder(requireContext(), viewModel.snapshot.recipient!!.id, -1L)
|
||||||
|
.withGiftBadge(viewModel.snapshot.giftBadge!!)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
requireActivity().startActivities(arrayOf(mainActivityIntent, conversationIntent))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPaymentError(throwable: Throwable?) {
|
||||||
|
Log.w(TAG, "onPaymentError", throwable, true)
|
||||||
|
|
||||||
|
if (errorDialog != null) {
|
||||||
|
Log.i(TAG, "Already displaying an error dialog. Skipping.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDialog = DonationErrorDialogs.show(
|
||||||
|
requireContext(), throwable,
|
||||||
|
object : DonationErrorDialogs.DialogCallback() {
|
||||||
|
override fun onDialogDismissed() {
|
||||||
|
requireActivity().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onToolbarNavigationClicked() {
|
||||||
|
findNavController().popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openEmojiSearch() {
|
||||||
|
emojiKeyboard.onOpenEmojiSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun closeEmojiSearch() {
|
||||||
|
emojiKeyboard.onCloseEmojiSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEmojiSelected(emoji: String?) {
|
||||||
|
if (emoji?.isNotEmpty() == true) {
|
||||||
|
eventPublisher.onNext(TextInput.TextInputEvent.OnEmojiEvent(emoji))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||||
|
if (keyEvent != null) {
|
||||||
|
eventPublisher.onNext(TextInput.TextInputEvent.OnKeyEvent(keyEvent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration
|
||||||
|
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||||
|
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider
|
||||||
|
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the user to select a recipient to send a gift to.
|
||||||
|
*/
|
||||||
|
class GiftFlowRecipientSelectionFragment : Fragment(R.layout.gift_flow_recipient_selection_fragment), MultiselectForwardFragment.Callback, SearchConfigurationProvider {
|
||||||
|
|
||||||
|
private val viewModel: GiftFlowViewModel by viewModels(
|
||||||
|
ownerProducer = { requireActivity() }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val toolbar = view.findViewById<Toolbar>(R.id.toolbar)
|
||||||
|
toolbar.setNavigationOnClickListener { requireActivity().onBackPressed() }
|
||||||
|
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(
|
||||||
|
R.id.multiselect_container,
|
||||||
|
MultiselectForwardFragment.create(
|
||||||
|
MultiselectForwardFragmentArgs(
|
||||||
|
canSendToNonPush = false,
|
||||||
|
multiShareArgs = emptyList(),
|
||||||
|
forceDisableAddMessage = true,
|
||||||
|
selectSingleRecipient = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSearchConfiguration(fragmentManager: FragmentManager, contactSearchState: ContactSearchState): ContactSearchConfiguration {
|
||||||
|
return ContactSearchConfiguration.build {
|
||||||
|
query = contactSearchState.query
|
||||||
|
|
||||||
|
if (query.isNullOrEmpty()) {
|
||||||
|
addSection(
|
||||||
|
ContactSearchConfiguration.Section.Recents(
|
||||||
|
includeSelf = false,
|
||||||
|
includeHeader = true,
|
||||||
|
mode = ContactSearchConfiguration.Section.Recents.Mode.INDIVIDUALS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSection(
|
||||||
|
ContactSearchConfiguration.Section.Individuals(
|
||||||
|
includeSelf = false,
|
||||||
|
transportType = ContactSearchConfiguration.TransportType.PUSH,
|
||||||
|
includeHeader = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinishForwardAction() = Unit
|
||||||
|
|
||||||
|
override fun exitFlow() = Unit
|
||||||
|
|
||||||
|
override fun onSearchInputFocused() = Unit
|
||||||
|
|
||||||
|
override fun setResult(bundle: Bundle) {
|
||||||
|
val parcelableContacts: List<ContactSearchKey.ParcelableRecipientSearchKey> = bundle.getParcelableArrayList(MultiselectForwardFragment.RESULT_SELECTION)!!
|
||||||
|
val contacts = parcelableContacts.map { it.asRecipientSearchKey() }
|
||||||
|
|
||||||
|
if (contacts.isNotEmpty()) {
|
||||||
|
viewModel.setSelectedContact(contacts.first())
|
||||||
|
findNavController().safeNavigate(R.id.action_giftFlowRecipientSelectionFragment_to_giftFlowConfirmationFragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getContainer(): ViewGroup = requireView() as ViewGroup
|
||||||
|
|
||||||
|
override fun getDialogBackgroundColor(): Int = Color.TRANSPARENT
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.signal.core.util.money.FiatMoney
|
||||||
|
import org.thoughtcrime.securesms.badges.Badges
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||||
|
import org.thoughtcrime.securesms.util.PlatformCurrencyUtil
|
||||||
|
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||||
|
import org.whispersystems.signalservice.internal.ServiceResponse
|
||||||
|
import java.util.Currency
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for grabbing gift badges and supported currency information.
|
||||||
|
*/
|
||||||
|
class GiftFlowRepository {
|
||||||
|
|
||||||
|
fun getGiftBadge(): Single<Pair<Long, Badge>> {
|
||||||
|
return ApplicationDependencies.getDonationsService()
|
||||||
|
.getGiftBadges(Locale.getDefault())
|
||||||
|
.flatMap(ServiceResponse<Map<Long, SignalServiceProfile.Badge>>::flattenResult)
|
||||||
|
.map { gifts -> gifts.map { it.key to Badges.fromServiceBadge(it.value) } }
|
||||||
|
.map { it.first() }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGiftPricing(): Single<Map<Currency, FiatMoney>> {
|
||||||
|
return ApplicationDependencies.getDonationsService()
|
||||||
|
.giftAmount
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.flatMap { it.flattenResult() }
|
||||||
|
.map { result ->
|
||||||
|
result
|
||||||
|
.filter { PlatformCurrencyUtil.getAvailableCurrencyCodes().contains(it.key) }
|
||||||
|
.mapKeys { (code, _) -> Currency.getInstance(code) }
|
||||||
|
.mapValues { (currency, price) -> FiatMoney(price, currency) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.gifts.flow
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.fragment.findNavController
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.models.CurrencySelection
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.subscription.models.NetworkFailure
|
||||||
|
import org.thoughtcrime.securesms.components.settings.configure
|
||||||
|
import org.thoughtcrime.securesms.components.settings.models.IndeterminateLoadingCircle
|
||||||
|
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||||
|
import org.thoughtcrime.securesms.util.ViewUtil
|
||||||
|
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||||
|
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Landing fragment for sending gifts.
|
||||||
|
*/
|
||||||
|
class GiftFlowStartFragment : DSLSettingsFragment(
|
||||||
|
layoutId = R.layout.gift_flow_start_fragment
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val viewModel: GiftFlowViewModel by viewModels(
|
||||||
|
ownerProducer = { requireActivity() },
|
||||||
|
factoryProducer = { GiftFlowViewModel.Factory(GiftFlowRepository(), requireListener<DonationPaymentComponent>().donationPaymentRepository) }
|
||||||
|
)
|
||||||
|
|
||||||
|
private val lifecycleDisposable = LifecycleDisposable()
|
||||||
|
|
||||||
|
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||||
|
CurrencySelection.register(adapter)
|
||||||
|
GiftRowItem.register(adapter)
|
||||||
|
NetworkFailure.register(adapter)
|
||||||
|
IndeterminateLoadingCircle.register(adapter)
|
||||||
|
|
||||||
|
val next = requireView().findViewById<View>(R.id.next)
|
||||||
|
next.setOnClickListener {
|
||||||
|
findNavController().safeNavigate(R.id.action_giftFlowStartFragment_to_giftFlowRecipientSelectionFragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||||
|
lifecycleDisposable += viewModel.state.observeOn(AndroidSchedulers.mainThread()).subscribe { state ->
|
||||||
|
next.isEnabled = state.stage == GiftFlowState.Stage.READY
|
||||||
|
|
||||||
|
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getConfiguration(state: GiftFlowState): DSLConfiguration {
|
||||||
|
return configure {
|
||||||
|
customPref(
|
||||||
|
CurrencySelection.Model(
|
||||||
|
selectedCurrency = state.currency,
|
||||||
|
isEnabled = state.stage == GiftFlowState.Stage.READY,
|
||||||
|
onClick = {
|
||||||
|
val action = GiftFlowStartFragmentDirections.actionGiftFlowStartFragmentToSetCurrencyFragment(true, viewModel.getSupportedCurrencyCodes().toTypedArray())
|
||||||
|
findNavController().safeNavigate(action)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("CascadeIf")
|
||||||
|
if (state.stage == GiftFlowState.Stage.FAILURE) {
|
||||||
|
customPref(
|
||||||
|
NetworkFailure.Model(
|
||||||
|
onRetryClick = {
|
||||||
|
viewModel.retry()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (state.stage == GiftFlowState.Stage.INIT) {
|
||||||
|
customPref(IndeterminateLoadingCircle)
|
||||||
|
} else if (state.giftBadge != null) {
|
||||||
|
state.giftPrices[state.currency]?.let {
|
||||||
|
customPref(
|
||||||
|
GiftRowItem.Model(
|
||||||
|
giftBadge = state.giftBadge,
|
||||||
|
price = it
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||