Compare commits
1248 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0d40685df | |||
| 4cbed24244 | |||
| 0d0c74f358 | |||
| 0dd2397fb4 | |||
| 3781e1dd60 | |||
| ae40a65924 | |||
| 8968ef1b85 | |||
| 25ab9a5ad6 | |||
| 5c27842a01 | |||
| 49a1a4a123 | |||
| ac90eeb42f | |||
| 302e653d2f | |||
| 3d6ffe25f0 | |||
| 363eb22462 | |||
| b04ae3a8b3 | |||
| e6451db888 | |||
| 2bbceaabd3 | |||
| fefbf595cd | |||
| 85b3947150 | |||
| a0d70a955a | |||
| a5c2595796 | |||
| 4193f7bcbd | |||
| 5102f5215c | |||
| bdd48629c6 | |||
| fde1e5ab77 | |||
| 46dd7f8a06 | |||
| 282639469d | |||
| bad1cc1571 | |||
| 3757449b8f | |||
| 0af65d1367 | |||
| adcb1bae13 | |||
| b69ffe4e15 | |||
| 8c34357cc6 | |||
| a17fd447a7 | |||
| eaf72b194f | |||
| 09a3391761 | |||
| e0e25da6a9 | |||
| 90d4069d0a | |||
| 0dcae81dba | |||
| 9177f5637a | |||
| 5918227bff | |||
| dd79688f48 | |||
| dbce4be31d | |||
| 4275877b47 | |||
| 11221315e4 | |||
| a4f44a96fd | |||
| ba54051f8c | |||
| 130b796564 | |||
| a15ba60252 | |||
| 8014a70134 | |||
| 4f73e36d72 | |||
| 68bd9c6e1e | |||
| 20d2c43356 | |||
| b94624fd5a | |||
| 4ae129d2af | |||
| 158505c8a8 | |||
| c98fd1a452 | |||
| c6d0ef218a | |||
| ba5b3e01f2 | |||
| e84ae83c28 | |||
| 33eeca9e3e | |||
| 6288dc19e9 | |||
| b9ba1a3568 | |||
| 93270b90df | |||
| a0235cbc6c | |||
| 28f0724d90 | |||
| 08a305cb0f | |||
| 50d2faf381 | |||
| 49b9d5c3aa | |||
| 7385112115 | |||
| 3feb73789d | |||
| b80c844a0b | |||
| 755a25519a | |||
| bbb9eab148 | |||
| c8e62e5f60 | |||
| 19818443ff | |||
| c30a43ef45 | |||
| 76539ff0f2 | |||
| 39f4ca10ef | |||
| 3d77ce0d57 | |||
| 3b9cfc8e5a | |||
| 761d70851c | |||
| 4c28619010 | |||
| 884710fc30 | |||
| 2e96042578 | |||
| 5b49be47f9 | |||
| d6a42daef7 | |||
| d5679ef95f | |||
| 60e54fb2af | |||
| 575e00dcf8 | |||
| a8a104242a | |||
| 372b0d9f2b | |||
| d3b061c6a4 | |||
| 1086749244 | |||
| c16115f71a | |||
| 6c608e955e | |||
| 31e0696395 | |||
| 99f43b997c | |||
| b6f84dfa16 | |||
| 63c98e92f2 | |||
| 34d4c910f7 | |||
| 7dc3454b37 | |||
| 60047aecb9 | |||
| 738c5db7c2 | |||
| c93457402c | |||
| 0a84f7f505 | |||
| c91a1e13d9 | |||
| a346dd33d9 | |||
| 398fdd84b9 | |||
| 2c5f57486c | |||
| 0fa3b2f8f9 | |||
| 88aa67b847 | |||
| 6154ff36c1 | |||
| c0a83e7956 | |||
| 59ad8bf76a | |||
| 4ba4df706e | |||
| d48632d09d | |||
| 8cb4cc5ac3 | |||
| 83d3e56dcf | |||
| deddb4f77d | |||
| 479ab10578 | |||
| 321c84583b | |||
| 3242d97c75 | |||
| 562a255478 | |||
| a6a70f23e9 | |||
| e3638791d9 | |||
| 8501fdffc6 | |||
| 4c4cbecd85 | |||
| 84833c9ad3 | |||
| 131a400921 | |||
| 54d937d036 | |||
| a8659bf8e5 | |||
| dfe78cdae6 | |||
| 9434894dff | |||
| d028165b51 | |||
| 8771dbf49f | |||
| 4615b0d32d | |||
| f9a2208832 | |||
| b981ac4fe4 | |||
| a3219348b6 | |||
| a35a35cee8 | |||
| 85f1f27b13 | |||
| 388c91410b | |||
| 3c0afe4b24 | |||
| ae1f834619 | |||
| 9f9bf3c604 | |||
| f9c4fe736a | |||
| 638bae6de3 | |||
| e363bac1a3 | |||
| e690e9bd69 | |||
| c0ac2176c1 | |||
| dccfafa9e8 | |||
| 0edfb0bd68 | |||
| 31a815013e | |||
| 4364e9513f | |||
| 1621c060b5 | |||
| b1c32476b0 | |||
| ba96db2ae0 | |||
| 182a112cdd | |||
| a45e26ab6b | |||
| b6022be41f | |||
| 0801a0e329 | |||
| 89cbfd3299 | |||
| 5b2ca6a1d3 | |||
| c5d7188dcb | |||
| 818eb81f87 | |||
| 510a295198 | |||
| 98fab95683 | |||
| 79d45bb497 | |||
| fc3d77ed9a | |||
| ee05cf87aa | |||
| ae18aed15b | |||
| a5aa079216 | |||
| ae7a03bc8f | |||
| 6ed797c031 | |||
| ef4015aec9 | |||
| ffedc3fa7d | |||
| 20285e7e5b | |||
| 89e55a7133 | |||
| 11aa168a6b | |||
| 0fc6e642fe | |||
| 8e0553c849 | |||
| 75b4ffc16e | |||
| 643b07d564 | |||
| 637a44379c | |||
| a2d42b0415 | |||
| a76983ca0a | |||
| 22e79a045c | |||
| 061b87ead0 | |||
| 511abd67c6 | |||
| 1627d92009 | |||
| 2cb67f6ee3 | |||
| 13e0b8dec0 | |||
| 7626070c28 | |||
| ca5140d3ec | |||
| 3694431503 | |||
| cd1f0632fa | |||
| 1508b1d401 | |||
| bf874e17e5 | |||
| d2b8a17723 | |||
| 67cfdf101d | |||
| 125840e5fc | |||
| f5ab4bec7a | |||
| ef7d5d55cb | |||
| 1a9d785cbb | |||
| cad0bab435 | |||
| bdc3435fc1 | |||
| f260633c9d | |||
| 8a00caabd7 | |||
| b4fe5bdcc6 | |||
| 1f649057d6 | |||
| 41059a2b67 | |||
| 3d65a957f4 | |||
| ff038e3ade | |||
| 44fa42fca4 | |||
| 73d8c74718 | |||
| db4a0deccc | |||
| 8b23a409ef | |||
| ec7e73bb7c | |||
| 321b85d5d0 | |||
| 98c9638bc4 | |||
| de1c9f2581 | |||
| 1af6af5045 | |||
| 0121811195 | |||
| 18cf55b156 | |||
| 0d4e109c72 | |||
| 3e358da83a | |||
| 85453ca442 | |||
| a5e5a73580 | |||
| 95f7b8d79f | |||
| 42d0d84ae0 | |||
| 686219d473 | |||
| 843ed24bbb | |||
| e17c49505c | |||
| 473747ee03 | |||
| 9ea97aabbb | |||
| 811d79c873 | |||
| 018782e63d | |||
| 01070a9cc0 | |||
| 14aecc4684 | |||
| 8aea20f147 | |||
| 87f175a96b | |||
| 6b5117a609 | |||
| 0ab66f81be | |||
| 12ec0ca84c | |||
| 915d56ac15 | |||
| ecc43f1dea | |||
| d8a4678b8f | |||
| 306875478e | |||
| 2df303cde7 | |||
| 4309127b8c | |||
| 39155b55a0 | |||
| 02dc457636 | |||
| 732a6324d6 | |||
| 54614e67aa | |||
| 15362c04fb | |||
| 658de3b6e7 | |||
| ab55fec6bd | |||
| 3a1f06f510 | |||
| 1ad0b0e6ae | |||
| 7ccc7ec856 | |||
| f0ab919ca5 | |||
| 8a05626791 | |||
| c0a468e42b | |||
| 8bee95eb02 | |||
| dedb78e454 | |||
| 2c1f30db1d | |||
| 6d3319bfb1 | |||
| e4b9832045 | |||
| 99aa4cbc98 | |||
| 1f952bd31e | |||
| 882bdcc726 | |||
| b0f43535c6 | |||
| 16ae2c870f | |||
| 18bb876d1b | |||
| dce8fde195 | |||
| 270ab34c6a | |||
| aa872d29bc | |||
| 6315d4b96c | |||
| 0cb53f40f4 | |||
| 51c86cab10 | |||
| 1f860d41b5 | |||
| 573de99840 | |||
| 68e0a30c92 | |||
| 6fc9db0aff | |||
| 737d893c87 | |||
| f06e1d9b98 | |||
| 4cff0a3369 | |||
| cc64a922d7 | |||
| e8c769bd1d | |||
| deba07d6cb | |||
| bacad359b2 | |||
| a9d7417597 | |||
| 6b94fc82eb | |||
| b9f060b442 | |||
| ca24682366 | |||
| 5047fc54f2 | |||
| 48c115eba1 | |||
| fd2677e8fe | |||
| f6bd27eff9 | |||
| ff41816fef | |||
| 1e6a17adc3 | |||
| 55aff18b1f | |||
| 5d6b3a8a75 | |||
| 31b98ec612 | |||
| 320bf45518 | |||
| 1893896254 | |||
| 19a95f479e | |||
| 5bcb7cece4 | |||
| f4f5fe2789 | |||
| e947212862 | |||
| 57f86b14fc | |||
| e2dc7fb5bf | |||
| 6499ed4637 | |||
| 8c45600365 | |||
| f8ef850fba | |||
| 151e2e5203 | |||
| 5dd3d8515f | |||
| 0f6c16c373 | |||
| 75bf3a7c7e | |||
| 48e47c9d92 | |||
| 3d45ab1b36 | |||
| 4d5d42157a | |||
| a6dfee16e9 | |||
| 0e8550748d | |||
| b82604953c | |||
| 100796b3b9 | |||
| f5af964286 | |||
| 2836a6060d | |||
| 80e31051e6 | |||
| 1fb0573fec | |||
| 5ba04936b1 | |||
| 011f6e6cf4 | |||
| ed3f992b83 | |||
| 782217a73d | |||
| a37b89feaf | |||
| e5b628b467 | |||
| 482a10de02 | |||
| c4164b17a2 | |||
| b8dc541fc5 | |||
| 2b6190bf34 | |||
| 2a70423a22 | |||
| 35c74573e7 | |||
| c26c455b3c | |||
| 4e2e525509 | |||
| ec83327eec | |||
| bafb62f214 | |||
| 38f5e8b4eb | |||
| 9827deffd3 | |||
| 65105fd3cb | |||
| 5d604c4e55 | |||
| 22221222bd | |||
| bad2f99968 | |||
| 392d582865 | |||
| 33dbf316a9 | |||
| 00a8565e91 | |||
| 0bac08dcc4 | |||
| 3b2dfb6ede | |||
| 997f6ef534 | |||
| fb0b1af056 | |||
| 3037a33267 | |||
| ff633ddd59 | |||
| cae5dad5d8 | |||
| 1a03b8fc1d | |||
| 049ba6a706 | |||
| f52364f75c | |||
| 87b699f3d8 | |||
| f73b8a7fd2 | |||
| 8af8468f4d | |||
| 49270e677e | |||
| 09dd2583b9 | |||
| dc22b27cd8 | |||
| 2a9eb1bae0 | |||
| c06fb81490 | |||
| af1b9579b4 | |||
| 7bbfc2d34c | |||
| 70355aa70e | |||
| 56c502c9bf | |||
| a05793c882 | |||
| 53f60f5a4c | |||
| 43d969f6b5 | |||
| a51bb8e23f | |||
| 5ceb3db0c4 | |||
| 9a65328c1b | |||
| 35eef0150d | |||
| 8511d3576f | |||
| f6542440c7 | |||
| 35393fc331 | |||
| f31e12572a | |||
| cef7878b47 | |||
| 3574be913a | |||
| b8cf0cc1be | |||
| b0788f7307 | |||
| cf9b91ebd4 | |||
| 1af15842cc | |||
| 17517cfc88 | |||
| 4615f246ac | |||
| 62ee60df82 | |||
| 4f3c545eda | |||
| b92a41ab70 | |||
| 6673da0b04 | |||
| 102f9de06f | |||
| 614d6ce04b | |||
| 5bb48caafd | |||
| 6c7d837964 | |||
| 755ec672c0 | |||
| 186bd9db48 | |||
| 48a81da883 | |||
| 2980e547cb | |||
| c9c2bbcf80 | |||
| 33da599ee0 | |||
| 113bcca277 | |||
| deca8e3feb | |||
| e02c8b9db7 | |||
| 314ea98393 | |||
| 0840cfc6e7 | |||
| de4cb931f3 | |||
| abde740ff7 | |||
| 9efe216070 | |||
| 2427c226a8 | |||
| ae73601f52 | |||
| 85551ca824 | |||
| 12565d28ae | |||
| f0a4956cdd | |||
| ba0befde20 | |||
| dd7652ad44 | |||
| b41303ba0d | |||
| a70ab94d24 | |||
| 10dd39abea | |||
| 5113f8b203 | |||
| 8f007a23cd | |||
| b34bb2e7d7 | |||
| 98fce53cf1 | |||
| ced05fe579 | |||
| fae21e4dbb | |||
| 5e3a3e1da9 | |||
| 03ad5073d2 | |||
| 3bd354289d | |||
| 8808526d0b | |||
| 0a19440ffc | |||
| 9815851bb9 | |||
| 1581a6e1cc | |||
| e3aa244f31 | |||
| 8fcce9fba5 | |||
| 7d49c77d1a | |||
| 947f59e81b | |||
| 7cac62f3f2 | |||
| 4578c33968 | |||
| 0160303d19 | |||
| 31aabd9851 | |||
| 7f39b9b50f | |||
| 69a2664668 | |||
| acebf5964c | |||
| ec2e3e29c3 | |||
| 0fc144d4a7 | |||
| 73025ec6de | |||
| 1d0e00648f | |||
| 42b5654a99 | |||
| 2eb787d78b | |||
| 1249cced2d | |||
| 0be1a30766 | |||
| ea253a2e67 | |||
| c4fadccf72 | |||
| fcf62512a7 | |||
| 16ab27084c | |||
| c1820459b7 | |||
| d88999d6d4 | |||
| 68655194a6 | |||
| f533a898f5 | |||
| 2167522f7d | |||
| f198b890fa | |||
| 85cb41050e | |||
| 00c131355f | |||
| 13ef53372e | |||
| f2cf77339e | |||
| 3e5be2cfe2 | |||
| c0a68202a7 | |||
| 07a6942ea8 | |||
| 41585699d2 | |||
| 2fcb240c2b | |||
| 566e981473 | |||
| 26e04ce6d2 | |||
| 2e2b4e1406 | |||
| b89e08dad7 | |||
| 5711b8a0fa | |||
| 62f9f19540 | |||
| 731683ae09 | |||
| 343aadcd9a | |||
| c4ad6c2992 | |||
| 97dd756136 | |||
| 7989c40f52 | |||
| 0749905909 | |||
| 168481fee5 | |||
| 7866e2e29c | |||
| 4eb0dca8f6 | |||
| bc54f6ca07 | |||
| 223c0c4bce | |||
| b39099b84e | |||
| 22d6546704 | |||
| a7af687f8e | |||
| ce9cd132ec | |||
| 62fa99e0ee | |||
| 43e4cba3d7 | |||
| 6cbc2f684d | |||
| ffc9e8caff | |||
| 49c9b0acde | |||
| 9c6908873c | |||
| 528fe67db9 | |||
| 39e14e922b | |||
| 0c8b6f8ef8 | |||
| f65de84c19 | |||
| 88074134af | |||
| b5cc570363 | |||
| 3cbf0933ff | |||
| 7f9c89483f | |||
| 8ef3d3fbbf | |||
| c225c2b37d | |||
| ff76c5fca5 | |||
| 5b99f590f8 | |||
| 2d0feca278 | |||
| 92e506b117 | |||
| cac841d8e6 | |||
| 77cb9bc174 | |||
| 309e33016a | |||
| 938b24f623 | |||
| 82c637ef4b | |||
| d9e8480a12 | |||
| 5115717f67 | |||
| 33ac48e771 | |||
| c53f1fcecf | |||
| 78704dce8a | |||
| 7f3ba1978d | |||
| 891dfc1b68 | |||
| b0ccb543d1 | |||
| 7752b3aba3 | |||
| 0fa13eb097 | |||
| 641db1cbe2 | |||
| 8d53c2392a | |||
| 8d0acb277c | |||
| 6e00920c95 | |||
| 13638dc1c9 | |||
| 1222d020ad | |||
| d82b1ec69b | |||
| 8052c13526 | |||
| ed8538547f | |||
| eb8de536e0 | |||
| 76728c43e0 | |||
| 52cfb57d36 | |||
| a385cb0b68 | |||
| e01381379c | |||
| d01a52c5a8 | |||
| 204fff1b9b | |||
| 1eda1477a8 | |||
| 3135685c0e | |||
| 58fdb26f04 | |||
| 9bcb1bad8e | |||
| eb6ef3d005 | |||
| f40ba0bf68 | |||
| 89df0a2c04 | |||
| 69fbd4f3fc | |||
| 45267f3590 | |||
| 3fb8c6eda8 | |||
| 27ce0fd65e | |||
| 7e91132e7e | |||
| 705839068a | |||
| 6625ac02d5 | |||
| 4b3580d98a | |||
| 6dbbec2631 | |||
| a7b6ebe7fc | |||
| 76f52b9086 | |||
| 3310246351 | |||
| f3d0b4a671 | |||
| 83b9fbac11 | |||
| 5ca843825f | |||
| e92c83401b | |||
| e18d9e665f | |||
| cc99febe32 | |||
| e72be42eff | |||
| bad382e2f3 | |||
| e637f15a43 | |||
| 6c55916cda | |||
| fbabab0b70 | |||
| e268887255 | |||
| 6b07922757 | |||
| a464e57079 | |||
| b5af691cc4 | |||
| 5c1b57e4ba | |||
| a5c51ff801 | |||
| d755e1e29e | |||
| b9361112b6 | |||
| 32101f7dda | |||
| 29e697265c | |||
| 4cd9ccc0f1 | |||
| 8936d81bc7 | |||
| cc36f83d77 | |||
| 64996a8db7 | |||
| 7267d77dcb | |||
| 2281e83607 | |||
| e6b03b1a4a | |||
| fb86fdfcd9 | |||
| 77cf029fdc | |||
| 556ca5a573 | |||
| 91645e6adc | |||
| 4d6bb95aa4 | |||
| 55ee68fa2d | |||
| 747bc7c3bf | |||
| 9c17201eaf | |||
| a24b3d9a60 | |||
| c93d882fe1 | |||
| 67403a6a9f | |||
| 5f9e72bb3c | |||
| 091b38ceb8 | |||
| 83dfb984fb | |||
| 9f14831fc4 | |||
| 48bfcc9b16 | |||
| 7028ca9411 | |||
| 5175375483 | |||
| e2dbaa605b | |||
| 93fd6e7a55 | |||
| b070e6962f | |||
| b1d1b7e31e | |||
| 1a5ae603d5 | |||
| 3b42bda63d | |||
| 2f70a71a6c | |||
| aae368c049 | |||
| dee3d2ff2d | |||
| da2e2a99af | |||
| 0c00426c0c | |||
| ccc96d5bfa | |||
| 82c12c2f6b | |||
| 9416beb4aa | |||
| 39b80a48c7 | |||
| d5491a2e84 | |||
| 07b19402e6 | |||
| 318b4703f2 | |||
| d389697f27 | |||
| 7bcc338a49 | |||
| ce2c2002c6 | |||
| d5fbd10406 | |||
| 6f6da699a3 | |||
| 62d8c115ba | |||
| fd01ee2a87 | |||
| 9aa517ad99 | |||
| 6c3e1b6a29 | |||
| 5d5063ef5f | |||
| b48668455c | |||
| 18ba5fa291 | |||
| 5e968eb831 | |||
| b4465953d8 | |||
| 08a7da3339 | |||
| c1a08616ab | |||
| 3761859681 | |||
| 8984b763fb | |||
| 59c62671b9 | |||
| bcfe8909e5 | |||
| c43fe44e3e | |||
| 4ac1134a9b | |||
| 08d03cb456 | |||
| e5c172a819 | |||
| 4569011e0b | |||
| 1031a4e96c | |||
| cdf8e4e1ed | |||
| b589449c34 | |||
| 7d7dd101df | |||
| e687fea567 | |||
| e2cb522e87 | |||
| 662ba85c5a | |||
| d29ebc7768 | |||
| 95fabd7ed1 | |||
| 5d5251054c | |||
| c766ba9808 | |||
| 8b5fe79849 | |||
| 903c5c6db6 | |||
| e2e0caa94a | |||
| 520fe481e9 | |||
| 7262aefa34 | |||
| 8815cdc3de | |||
| 8df86962e9 | |||
| 573c0fad7f | |||
| a8419d5f02 | |||
| 6088f16e3a | |||
| 1a5ae592a7 | |||
| 6880dfeb62 | |||
| dfecb0efd8 | |||
| 2eaadd4337 | |||
| 655f3c1219 | |||
| 1494a3559d | |||
| f61d7a9f77 | |||
| ee0ab8f035 | |||
| 6e85c74e3f | |||
| 3b1aa5b176 | |||
| 715ad0d459 | |||
| 05f7dce503 | |||
| ecfbeb69c5 | |||
| c7fb343b93 | |||
| b0d0814b88 | |||
| 9f3765d368 | |||
| fe82c4e487 | |||
| e9bbb1b9ae | |||
| fd3e88707c | |||
| 8e8def8b03 | |||
| 5b1069018f | |||
| abc71f4fb4 | |||
| 2f5e95c0e3 | |||
| d1fd70a807 | |||
| 1b924c606a | |||
| 9b175fa0dd | |||
| 7e7bbad788 | |||
| d8c82add78 | |||
| 6e1657b1bd | |||
| 1f7b1d91c4 | |||
| e7833df539 | |||
| fae80a242d | |||
| 77ff25ec49 | |||
| bb446ac1d5 | |||
| b6a4d01d42 | |||
| bd4dd25460 | |||
| f86c1fe508 | |||
| 38f6efbcae | |||
| 30a542234b | |||
| 8c9bf678fa | |||
| 4b465b74e8 | |||
| 58a22f0eea | |||
| ddad9acef1 | |||
| 1dbb6013cb | |||
| 9cc1ae4a29 | |||
| 4eb24c3303 | |||
| ec1935572e | |||
| e419d70d51 | |||
| 2af5526879 | |||
| 6a5aa089ae | |||
| 6b5f4ca8c2 | |||
| 53e110560a | |||
| 82e9c620e8 | |||
| 076facbdc2 | |||
| a805f9b6b4 | |||
| 969e763997 | |||
| 9347227ff5 | |||
| c9ba0432a0 | |||
| e3b7fe7509 | |||
| 5332669321 | |||
| a086305c38 | |||
| a712622891 | |||
| 1514f91687 | |||
| 0dfa6aab09 | |||
| 4b6dbac758 | |||
| b816f901a5 | |||
| 76d1490810 | |||
| f2ab0b6423 | |||
| e09d162c1e | |||
| c84de8fa60 | |||
| 8e020c05f6 | |||
| 8c9eb880cf | |||
| d7ddd85a90 | |||
| 7d994b2ae1 | |||
| 664d6475d9 | |||
| a940487611 | |||
| 9f995d61f4 | |||
| a6690e1bde | |||
| d507df2e7e | |||
| fa26eb2017 | |||
| 0b53ba8950 | |||
| 7447e2497b | |||
| 7ac83625d3 | |||
| 50dfe7bc25 | |||
| 8e32592218 | |||
| a3d72fc06c | |||
| f5a6d61362 | |||
| bca2205945 | |||
| 1241f4c0e9 | |||
| f6253ad0bb | |||
| 083301185c | |||
| 0273d0f285 | |||
| 3dc1ce3353 | |||
| f8e077b824 | |||
| aec2ca1d87 | |||
| 6e7a18ea11 | |||
| fe54ec9d6c | |||
| 1819af3000 | |||
| 3c177c4883 | |||
| 2c871a36d0 | |||
| 6bde55f73b | |||
| 50b4e137b4 | |||
| 4f6d39859c | |||
| 45a6894da1 | |||
| f71accea06 | |||
| 32888fa00b | |||
| eba3c55ec8 | |||
| 21b82e291b | |||
| 8d9d84c4cc | |||
| 4c25264fbf | |||
| 7410d664dd | |||
| c878ba3cdf | |||
| 97798a146f | |||
| 7c134a6c9d | |||
| 08008629b3 | |||
| a57adcb2b0 | |||
| 7790cac0ee | |||
| 349ad06c45 | |||
| 3a75d30732 | |||
| b48d4f3ec2 | |||
| c92f36f9a8 | |||
| faa36d417c | |||
| a2b6e003b6 | |||
| 406af58394 | |||
| bd72fc8464 | |||
| 05fb1a52d2 | |||
| b21abb8e7e | |||
| b41e602539 | |||
| 3f233ed39f | |||
| ade6f60e76 | |||
| 62d85e6878 | |||
| 4d985255a8 | |||
| fd3ef0f557 | |||
| 7f30300cd4 | |||
| 0459d118a3 | |||
| c92f3b5dfd | |||
| ba4d1c9844 | |||
| 8c3a0c1f9f | |||
| 1dc2a35d83 | |||
| 0a67731830 | |||
| 28d86886bd | |||
| b1fcea673a | |||
| eb5418787a | |||
| adbda02aa4 | |||
| 307f47fa33 | |||
| c1fb4f9421 | |||
| 6179c087fb | |||
| ae2ba5d185 | |||
| 91128be8f6 | |||
| 8748056130 | |||
| 3c4e3cf048 | |||
| eb48ab1784 | |||
| 665d9e31f6 | |||
| db7272730e | |||
| 5787a5f68a | |||
| 1a21cafe6c | |||
| 7465818f44 | |||
| 62cb29fdb7 | |||
| a85b08d9da | |||
| b18c3ec1a9 | |||
| 29489a664e | |||
| dbb1e50d00 | |||
| 2068fa8041 | |||
| 194975d068 | |||
| b7a067e954 | |||
| 1e050915ef | |||
| 6a5c234408 | |||
| 7a1122b3f7 | |||
| 962d943a22 | |||
| dbcc5d696d | |||
| 9232eb7c16 | |||
| fc9b8f43dd | |||
| 5e8d74bc11 | |||
| 642d1984c4 | |||
| 0ab2100fa5 | |||
| 6618d696e4 | |||
| c24dfdce34 | |||
| 214e994e90 | |||
| b904de5b50 | |||
| ad7c81ef4e | |||
| 3e8b5cdb61 | |||
| 6aea849a42 | |||
| cd0bf470a9 | |||
| c615b14c51 | |||
| 28bf6d300e | |||
| a1095f966c | |||
| 58a8902d4e | |||
| e582976293 | |||
| 143110047d | |||
| c1324c7496 | |||
| 53eee2bd16 | |||
| 86b1d104d9 | |||
| d1d2376210 | |||
| 7bede7e98a | |||
| fec4a7692d | |||
| b58cede072 | |||
| 199fb517b1 | |||
| 921addf4c8 | |||
| 61aa991d79 | |||
| c1c95e1ae2 | |||
| f95a29b0d4 | |||
| f7bb9c85af | |||
| ae30e4070c | |||
| 9a67c60b4e | |||
| e86b26bd11 | |||
| e7c259b1e9 | |||
| c65761a034 | |||
| 0b37b0ee16 | |||
| d76e58ce09 | |||
| 2b366f8c9c | |||
| d43f7d6ad9 | |||
| 5b7932281e | |||
| 0599f76ed5 | |||
| 31e0f3edfb | |||
| 17b568e6d1 | |||
| 7c11962cb3 | |||
| a7c4199192 | |||
| 8cb3909093 | |||
| 7480ea66ec | |||
| 8e94ced7b6 | |||
| ffd86a96da | |||
| d4cabce876 | |||
| a5790edb2b | |||
| d247e2eabe | |||
| f4d6de466b | |||
| 0838c0be27 | |||
| 7448183ff4 | |||
| 8e2a265cf3 | |||
| 8802cebb64 | |||
| 0c6fe8bea3 | |||
| 49334ffd42 | |||
| 4702ab1aeb | |||
| fe8fcb1394 | |||
| dc1e56de4e | |||
| 561cca5208 | |||
| a291732c1a | |||
| 28abc1e4ff | |||
| 655e43a079 | |||
| 94b9a458e7 | |||
| bb75730315 | |||
| 824a8ac5f2 | |||
| 3baf10f0e9 | |||
| cfab195e90 | |||
| 503d7c77a0 | |||
| f99ff32947 | |||
| 182c758d35 | |||
| f6b2d3faf8 | |||
| 61c7959ffc | |||
| 67ccd14af2 | |||
| 3c41b7322f | |||
| d2118d0b53 | |||
| 89af85c893 | |||
| 0762a93787 | |||
| 570b4d7150 | |||
| fc51c4940c | |||
| b9ffbb8e92 | |||
| de2c7d38bf | |||
| c9597ef8dc | |||
| a9bbee3880 | |||
| 2bac1a7707 | |||
| 80e1b2c843 | |||
| 7c2da69676 | |||
| f25e8d602b | |||
| b9c6c6b0f4 | |||
| 164f39e376 | |||
| 49190125ef | |||
| 555e65d3ee | |||
| 89b1243885 | |||
| 3fca46de92 | |||
| aa0d7c218f | |||
| 2b5b664a8f | |||
| 784c373a0e | |||
| 37ae740138 | |||
| fe9b8a9f47 | |||
| 3585667fb9 | |||
| b4ed088529 | |||
| bdcf390e6e | |||
| c7551881b8 | |||
| c131754874 | |||
| c6c4988583 | |||
| d43f044eb4 | |||
| 9c71994804 | |||
| 654d98b0fe | |||
| c06056d847 | |||
| 615fbf87c9 | |||
| aca3d150bf | |||
| 6eae2d39a8 | |||
| c78e283084 | |||
| 2d5492ffac | |||
| 2830132b24 | |||
| e374f3afe6 | |||
| 58a2e50904 | |||
| be29dce7b7 | |||
| e58b617689 | |||
| 9d3a9fc675 | |||
| 8bddf5206c | |||
| 0ac234e7bf | |||
| 290f84e5b1 | |||
| 149c138666 | |||
| 065a39992a | |||
| 4a52532256 | |||
| 93f541ceca | |||
| e97a1b2cf6 | |||
| f6b46f921c | |||
| 5fef0494b1 | |||
| 6e1621fef1 | |||
| e5f1793eb3 | |||
| a994712609 | |||
| 3b8eac0b8d | |||
| 52978b1b42 | |||
| 922d0d7203 | |||
| 429fdf0d76 | |||
| 1f718009bd | |||
| c1c9ca7c4c | |||
| 75421b1af8 | |||
| d40bb2d9ee | |||
| 7c8549bf5e | |||
| fb8f481a87 | |||
| 8caa690086 | |||
| d7011e3353 | |||
| 9af966b030 | |||
| a46accfcc0 | |||
| c0c4092cd9 | |||
| 9398716848 | |||
| 25234496bf | |||
| 1a56924a56 | |||
| d168d35362 | |||
| 3cc2cd0b17 | |||
| 138b7ea796 | |||
| 1f1a4eb351 | |||
| b9081dc942 | |||
| e76808a000 | |||
| e31fd8d578 | |||
| 7d8f780d60 | |||
| 0478757af4 | |||
| 712b0c147a | |||
| fc6db45e59 | |||
| 5229e24397 | |||
| 927b6096c6 | |||
| 16f1128990 | |||
| 4fd1d05503 | |||
| dfac05a118 | |||
| cd869bcb89 | |||
| 427119cef2 | |||
| dada7a4f06 | |||
| 44a84210d8 | |||
| 5ac8d3b0bd | |||
| 7ccba5b1c8 | |||
| c2ffd8adbb | |||
| 7e4396ae3f | |||
| d0827eb48e | |||
| 90397165c3 | |||
| e3e47504a6 | |||
| 42269efa57 | |||
| 38adb0373d | |||
| 8bde389398 | |||
| d29b0609a3 | |||
| 740977164b | |||
| 2dd8f24e14 | |||
| 4e409fc9ed | |||
| 136826be69 | |||
| 0ba7ff911b | |||
| bfbdbdcbc0 | |||
| f2533ac4b7 | |||
| 15a5f5966d | |||
| 3c748b2df6 | |||
| c1b54b3532 | |||
| ab56856f41 | |||
| ce31e642dd | |||
| aa67c82634 | |||
| c9c4187d2e | |||
| 60b4862b1b | |||
| b2c3a34d68 | |||
| 90925f4d8c | |||
| 833f90ce53 | |||
| 26c9b5166e | |||
| a27d60f830 | |||
| a75f634c0a | |||
| 963c018e0c | |||
| 6cc0eed5fe | |||
| cdcc7b6fa5 | |||
| 24482b5a65 | |||
| b100262c6a | |||
| ed23c3fe7c | |||
| 0093e1d3eb | |||
| 7419da7247 | |||
| 0b85852621 | |||
| 556518973d | |||
| b9514d0b94 | |||
| a9dab90a1e | |||
| 39709c8d64 | |||
| c2a6963a6d | |||
| bfdebbfa5d | |||
| 167a691018 | |||
| 8004565c84 | |||
| a101dc4fd1 | |||
| 57f730d8ee | |||
| 3543cc80ba | |||
| 71613d9db1 | |||
| 4a0e6a3eb2 | |||
| f1a87518e1 | |||
| 61f880fd78 | |||
| 09904e7a16 | |||
| 94658e9090 | |||
| a47448b6c6 | |||
| 7e4b9b685a | |||
| 64922a8e51 | |||
| f65f4704c9 | |||
| b04ca202f6 | |||
| 83086a5a2b | |||
| 51a521594f | |||
| 0a7a7cf5a9 | |||
| 6bd689504c | |||
| efec40ff57 | |||
| 69716dde4a | |||
| e90fa05d60 | |||
| 580c000bda | |||
| 2f3d04d3e8 | |||
| bf37d412e9 | |||
| fd115ebb72 | |||
| b9657208fe | |||
| 5d6d78a51e | |||
| 916006e664 | |||
| 55c69cd50a | |||
| 14565b0864 | |||
| a157c1ae1d | |||
| a4d458f969 | |||
| 3f53abedab | |||
| 68a2d5ed20 | |||
| 35e9e31a7b | |||
| 444d947743 | |||
| c427dbad08 | |||
| 2cefe813e4 | |||
| 123ffe42c3 | |||
| da20e66ecd | |||
| 901440017a | |||
| 0be76a37fe | |||
| 36dadc8777 | |||
| 182749c101 | |||
| d9228bd911 | |||
| a361fcc8f3 | |||
| ff4f0b9f42 | |||
| 060dffc9cc | |||
| 172cc302fc | |||
| 416e62112f | |||
| e584a90f81 | |||
| 9876ffb5e4 | |||
| 53e10f2cad | |||
| cb79f75ac1 | |||
| 5ec9c1cd90 | |||
| 1f28a30ace | |||
| 7715917436 | |||
| f79b445fdf | |||
| 14484deabe | |||
| 3ac395d33e | |||
| f83b520ca9 | |||
| 0123f9aa87 | |||
| 06b64fe619 | |||
| 1bb87834d8 | |||
| ae4167ddae | |||
| 383beafdef | |||
| 062e88b24f | |||
| 8299d49042 | |||
| 4677883838 | |||
| 7f0a0bef5a | |||
| acc825971b | |||
| 62040d06b4 | |||
| 0921ebe5f1 | |||
| 3d0e15e2b8 | |||
| 5372f79c40 | |||
| 92e8f9de0e | |||
| c3cf846a10 | |||
| 5826b0c068 | |||
| 2d7c043398 | |||
| e20d6b63cf | |||
| b85c5eb54a | |||
| a1c8573fad | |||
| 90a27d2227 | |||
| c54c6018b2 | |||
| 7419570f94 | |||
| 8860f792c4 | |||
| e47db0d532 | |||
| ab5d3badc2 | |||
| fce362960f | |||
| 5bf23dcfb3 | |||
| 65c7dc6ca2 | |||
| e30a8b6954 | |||
| 838e318200 | |||
| 62ee411901 | |||
| ceefd2d92f | |||
| e3870f5656 | |||
| 6e7022ab70 | |||
| 031d1551e7 | |||
| 6755b25361 | |||
| 11d0a73675 | |||
| 44119b6437 | |||
| d4a3b442f4 | |||
| aba5774446 | |||
| 911dd9efb1 | |||
| f2a490b07e | |||
| 5675f080f2 | |||
| f0dbe230b5 | |||
| 8b81800052 | |||
| f598c14298 | |||
| 58b070e6e3 | |||
| 71c92a1c90 | |||
| b86acb9773 | |||
| 1b8758b657 | |||
| ed4bab1b8b | |||
| a71fe0fd75 | |||
| 3d2a634aac | |||
| 01047f0546 | |||
| 9dac5691f0 | |||
| 3c489ad247 | |||
| 7797351341 | |||
| f7212b9916 | |||
| 93bb49dc16 | |||
| e504c490c8 | |||
| 42e865813c | |||
| fc14d1d464 | |||
| 2a1e5e4471 | |||
| da2ee33dff | |||
| f19033a7a2 | |||
| 6502ef64ce | |||
| b3ebf778fd | |||
| 1dca3698d2 | |||
| 2bfe1198d1 | |||
| 4f704670b1 | |||
| a1aafd7453 | |||
| 4932623937 | |||
| b93568d9c6 | |||
| b3041ab6e0 | |||
| 3a151b30ac | |||
| 97b3d36433 | |||
| 81e3252128 | |||
| 426c83c6cc | |||
| b427754a81 | |||
| 08f023fb12 | |||
| 5f1454aeb8 | |||
| 0d254e0724 | |||
| e882e6e111 | |||
| 4b0811f9aa | |||
| 817f1ee938 | |||
| 2d93d74b9f | |||
| 93f37ad70f | |||
| 3c6bed90db | |||
| fa26fb6b8b | |||
| 263ddb0d1e | |||
| 8be659c1c8 | |||
| e5c9dddb5a | |||
| 6da72aad6d | |||
| 5dd5a024c9 | |||
| c0eac5564c | |||
| 0d0ee753df | |||
| 908f952893 | |||
| 1c80e65c5a | |||
| 20b13a929b | |||
| 4637e1b5d8 | |||
| 4b6cb79c75 | |||
| feaf2a33a9 | |||
| 4c893a11fc | |||
| f4dd80c929 | |||
| 4af078007e | |||
| be297120a1 | |||
| a9741cadbf | |||
| 79200c82da | |||
| d9c9ae8dae |
@@ -50,5 +50,5 @@ Describe here the issue that you are experiencing.
|
|||||||
**Signal version:** 0.0.0
|
**Signal version:** 0.0.0
|
||||||
|
|
||||||
### Link to debug log
|
### Link to debug log
|
||||||
<!-- immediately after the bug has happened capture a debug log via Signal's advanced settings and paste the link below -->
|
<!-- immediately after the bug has happened capture a debug log via Signal's settings (Help -> Debug log) and paste the link below -->
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: set up JDK 1.8
|
- name: set up JDK 11
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v1
|
||||||
with:
|
with:
|
||||||
java-version: 1.8
|
java-version: 11
|
||||||
|
|
||||||
- name: Validate Gradle Wrapper
|
- name: Validate Gradle Wrapper
|
||||||
uses: gradle/wrapper-validation-action@v1
|
uses: gradle/wrapper-validation-action@v1
|
||||||
|
|
||||||
|
- name: Remove Android 31 (S)
|
||||||
|
run: $ANDROID_HOME/tools/bin/sdkmanager --uninstall "platforms;android-31"
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
run: ./gradlew qa
|
run: ./gradlew qa
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,48 @@
|
|||||||
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
|
<option name="DO_NOT_WRAP_AFTER_SINGLE_ANNOTATION" value="true" />
|
||||||
<option name="ALIGN_MULTILINE_ANNOTATION_PARAMETERS" value="true" />
|
<option name="ALIGN_MULTILINE_ANNOTATION_PARAMETERS" value="true" />
|
||||||
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
|
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
|
||||||
|
<option name="IMPORT_LAYOUT_TABLE">
|
||||||
|
<value>
|
||||||
|
<package name="android" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="androidx" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="com" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="junit" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="net" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="org" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="java" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="javax" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="" withSubpackages="true" static="false" />
|
||||||
|
<emptyLine />
|
||||||
|
<package name="android" withSubpackages="true" static="true" />
|
||||||
|
<package name="androidx" withSubpackages="true" static="true" />
|
||||||
|
<package name="com" withSubpackages="true" static="true" />
|
||||||
|
<package name="junit" withSubpackages="true" static="true" />
|
||||||
|
<package name="net" withSubpackages="true" static="true" />
|
||||||
|
<package name="org" withSubpackages="true" static="true" />
|
||||||
|
<package name="java" withSubpackages="true" static="true" />
|
||||||
|
<package name="javax" withSubpackages="true" static="true" />
|
||||||
|
<package name="" withSubpackages="true" static="true" />
|
||||||
|
<emptyLine />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
</JavaCodeStyleSettings>
|
</JavaCodeStyleSettings>
|
||||||
<JetCodeStyleSettings>
|
<JetCodeStyleSettings>
|
||||||
|
<option name="PACKAGES_TO_USE_STAR_IMPORTS">
|
||||||
|
<value>
|
||||||
|
<package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
|
||||||
|
<package name="io.ktor" alias="false" withSubpackages="true" />
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="2147483647" />
|
||||||
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
<option name="NAME_COUNT_TO_USE_STAR_IMPORT_FOR_MEMBERS" value="2147483647" />
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
</JetCodeStyleSettings>
|
</JetCodeStyleSettings>
|
||||||
<codeStyleSettings language="JAVA">
|
<codeStyleSettings language="JAVA">
|
||||||
<option name="BRACE_STYLE" value="5" />
|
<option name="BRACE_STYLE" value="5" />
|
||||||
@@ -178,13 +215,5 @@
|
|||||||
</rules>
|
</rules>
|
||||||
</arrangement>
|
</arrangement>
|
||||||
</codeStyleSettings>
|
</codeStyleSettings>
|
||||||
<codeStyleSettings language="kotlin">
|
|
||||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
|
||||||
<indentOptions>
|
|
||||||
<option name="INDENT_SIZE" value="2" />
|
|
||||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
|
||||||
<option name="TAB_SIZE" value="2" />
|
|
||||||
</indentOptions>
|
|
||||||
</codeStyleSettings>
|
|
||||||
</code_scheme>
|
</code_scheme>
|
||||||
</component>
|
</component>
|
||||||
@@ -4,7 +4,7 @@ Signal is a messaging app for simple private communication with friends.
|
|||||||
|
|
||||||
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
|
Signal uses your phone's data connection (WiFi/3G/4G) to communicate securely, optionally supports plain SMS/MMS to function as a unified messenger, and can also encrypt the stored messages on your phone.
|
||||||
|
|
||||||
Currently available on the Play store.
|
Currently available on the Play store and [signal.org](https://signal.org/android/apk/).
|
||||||
|
|
||||||
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
|
<a href='https://play.google.com/store/apps/details?id=org.thoughtcrime.securesms&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/images/generic/en_badge_web_generic.png' height='80px'/></a>
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@ The form and manner of this distribution makes it eligible for export under the
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Copyright 2013-2020 Signal
|
Copyright 2013-2021 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
|
||||||
|
|
||||||
Google Play and the Google Play logo are trademarks of Google Inc.
|
Google Play and the Google Play logo are trademarks of Google LLC.
|
||||||
|
|||||||
@@ -1,48 +1,50 @@
|
|||||||
import org.signal.signing.ApkSignerUtil
|
|
||||||
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply plugin: 'kotlin-kapt'
|
|
||||||
apply plugin: 'com.google.protobuf'
|
apply plugin: 'com.google.protobuf'
|
||||||
apply plugin: 'androidx.navigation.safeargs'
|
apply plugin: 'androidx.navigation.safeargs'
|
||||||
apply plugin: 'witness'
|
|
||||||
apply plugin: 'org.jlleitschuh.gradle.ktlint'
|
apply plugin: 'org.jlleitschuh.gradle.ktlint'
|
||||||
apply from: 'translations.gradle'
|
apply from: 'translations.gradle'
|
||||||
apply from: 'witness-verifications.gradle'
|
apply plugin: 'org.jetbrains.kotlin.android'
|
||||||
|
apply plugin: 'app.cash.exhaustive'
|
||||||
|
apply plugin: 'kotlin-parcelize'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
maven {
|
|
||||||
url "https://raw.github.com/signalapp/maven/master/photoview/releases/"
|
|
||||||
content {
|
|
||||||
includeGroupByRegex "com\\.github\\.chrisbanes.*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maven {
|
maven {
|
||||||
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
|
url "https://raw.github.com/signalapp/maven/master/circular-progress-button/releases/"
|
||||||
content {
|
content {
|
||||||
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
|
includeGroupByRegex "com\\.github\\.dmytrodanylyk\\.circular-progress-button\\.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maven { // textdrawable
|
maven {
|
||||||
url 'https://dl.bintray.com/amulyakhare/maven'
|
url "https://raw.github.com/signalapp/maven/master/sqlcipher/release/"
|
||||||
content {
|
content {
|
||||||
includeGroupByRegex "com\\.amulyakhare.*"
|
includeGroupByRegex "org\\.signal.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven {
|
||||||
|
url "https://www.jitpack.io"
|
||||||
|
}
|
||||||
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
jcenter()
|
|
||||||
mavenLocal()
|
mavenLocal()
|
||||||
maven {
|
maven {
|
||||||
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
|
url "https://dl.cloudsmith.io/qxAgwaeEE1vN8aLU/mobilecoin/mobilecoin/maven/"
|
||||||
}
|
}
|
||||||
|
jcenter {
|
||||||
|
content {
|
||||||
|
includeVersion "mobi.upod", "time-duration-picker", "1.1.3"
|
||||||
|
includeVersion "cn.carbswang.android", "NumberPickerView", "1.0.9"
|
||||||
|
includeVersion "com.takisoft.fix", "colorpicker", "0.9.1"
|
||||||
|
includeVersion "com.codewaves.stickyheadergrid", "stickyheadergrid", "0.9.4"
|
||||||
|
includeVersion "com.google.android", "flexbox", "0.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
protoc {
|
protoc {
|
||||||
artifact = 'com.google.protobuf:protoc:3.10.0'
|
artifact = 'com.google.protobuf:protoc:3.18.0'
|
||||||
}
|
}
|
||||||
generateProtoTasks {
|
generateProtoTasks {
|
||||||
all().each { task ->
|
all().each { task ->
|
||||||
@@ -55,8 +57,8 @@ protobuf {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
def canonicalVersionCode = 864
|
def canonicalVersionCode = 982
|
||||||
def canonicalVersionName = "5.14.3"
|
def canonicalVersionName = "5.28.9"
|
||||||
|
|
||||||
def postFixSize = 100
|
def postFixSize = 100
|
||||||
def abiPostFix = ['universal' : 0,
|
def abiPostFix = ['universal' : 0,
|
||||||
@@ -67,6 +69,24 @@ def abiPostFix = ['universal' : 0,
|
|||||||
|
|
||||||
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
def keystores = [ 'debug' : loadKeystoreProperties('keystore.debug.properties') ]
|
||||||
|
|
||||||
|
def selectableVariants = [
|
||||||
|
'nightlyProdFlipper',
|
||||||
|
'nightlyProdPerf',
|
||||||
|
'nightlyProdRelease',
|
||||||
|
'playProdDebug',
|
||||||
|
'playProdFlipper',
|
||||||
|
'playProdPerf',
|
||||||
|
'playProdRelease',
|
||||||
|
'playStagingDebug',
|
||||||
|
'playStagingFlipper',
|
||||||
|
'playStagingPerf',
|
||||||
|
'playStagingRelease',
|
||||||
|
'studyProdMock',
|
||||||
|
'studyProdPerf',
|
||||||
|
'websiteProdFlipper',
|
||||||
|
'websiteProdRelease',
|
||||||
|
]
|
||||||
|
|
||||||
android {
|
android {
|
||||||
buildToolsVersion BUILD_TOOL_VERSION
|
buildToolsVersion BUILD_TOOL_VERSION
|
||||||
compileSdkVersion COMPILE_SDK
|
compileSdkVersion COMPILE_SDK
|
||||||
@@ -75,6 +95,7 @@ android {
|
|||||||
useLibrary 'org.apache.http.legacy'
|
useLibrary 'org.apache.http.legacy'
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
freeCompilerArgs = ["-Xallow-result-return-type"]
|
freeCompilerArgs = ["-Xallow-result-return-type"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,31 +128,42 @@ android {
|
|||||||
|
|
||||||
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
buildConfigField "long", "BUILD_TIMESTAMP", getLastCommitTimestamp() + "L"
|
||||||
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
|
||||||
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service.whispersystems.org\""
|
buildConfigField "String", "SIGNAL_URL", "\"https://chat.signal.org\""
|
||||||
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
buildConfigField "String", "STORAGE_URL", "\"https://storage.signal.org\""
|
||||||
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_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\""
|
||||||
|
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_NAMES", "new String[]{\"Test\", \"Staging\"}"
|
||||||
|
buildConfigField "String[]", "SIGNAL_SFU_INTERNAL_URLS", "new String[]{\"https://sfu.test.voip.signal.org\", \"https://sfu.staging.voip.signal.org\"}"
|
||||||
buildConfigField "String", "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_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 "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\"," +
|
||||||
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
"\"fe7c1bfae98f9b073d220366ea31163ee82f6d04bead774f71ca8e5c40847bfe\", " +
|
||||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
|
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")";
|
||||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||||
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+PEjH9DXItNEqs2sNcug37xZQDLm7X0=\""
|
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_REGIONS", "new int[]{44}"
|
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", "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_ENVIRONMENT_TYPE", "\"unset\""
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"unset\""
|
||||||
|
buildConfigField "String", "BADGE_STATIC_ROOT", "\"https://updates2.signal.org/static/badges/\""
|
||||||
|
buildConfigField "String", "STRIPE_PUBLISHABLE_KEY", "\"pk_live_6cmGZopuTsV8novGgJJW9JpC00vLIgtQ1D\""
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64'
|
||||||
}
|
}
|
||||||
@@ -148,6 +180,21 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
testInstrumentationRunnerArguments clearPackageData: 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
execution 'ANDROIDX_TEST_ORCHESTRATOR'
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
test {
|
||||||
|
java.srcDirs += "$projectDir/src/testShared"
|
||||||
|
}
|
||||||
|
|
||||||
|
androidTest {
|
||||||
|
java.srcDirs += "$projectDir/src/testShared"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
@@ -196,28 +243,34 @@ android {
|
|||||||
'proguard/proguard.cfg'
|
'proguard/proguard.cfg'
|
||||||
testProguardFiles 'proguard/proguard-automation.pro',
|
testProguardFiles 'proguard/proguard-automation.pro',
|
||||||
'proguard/proguard.cfg'
|
'proguard/proguard.cfg'
|
||||||
|
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Debug\""
|
||||||
}
|
}
|
||||||
flipper {
|
flipper {
|
||||||
initWith debug
|
initWith debug
|
||||||
isDefault false
|
isDefault false
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
matchingFallbacks = ['debug']
|
matchingFallbacks = ['debug']
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Flipper\""
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
proguardFiles = buildTypes.debug.proguardFiles
|
proguardFiles = buildTypes.debug.proguardFiles
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Release\""
|
||||||
}
|
}
|
||||||
perf {
|
perf {
|
||||||
initWith debug
|
initWith debug
|
||||||
isDefault false
|
isDefault false
|
||||||
debuggable false
|
debuggable false
|
||||||
matchingFallbacks = ['debug']
|
matchingFallbacks = ['debug']
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Perf\""
|
||||||
}
|
}
|
||||||
mock {
|
mock {
|
||||||
initWith debug
|
initWith debug
|
||||||
isDefault false
|
isDefault false
|
||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
matchingFallbacks = ['debug']
|
matchingFallbacks = ['debug']
|
||||||
|
buildConfigField "String", "BUILD_VARIANT_TYPE", "\"Mock\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +281,7 @@ android {
|
|||||||
ext.websiteUpdateUrl = "null"
|
ext.websiteUpdateUrl = "null"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||||
|
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"play\""
|
||||||
}
|
}
|
||||||
|
|
||||||
website {
|
website {
|
||||||
@@ -235,13 +289,16 @@ android {
|
|||||||
ext.websiteUpdateUrl = "https://updates.signal.org/android"
|
ext.websiteUpdateUrl = "https://updates.signal.org/android"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "true"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "\"$ext.websiteUpdateUrl\""
|
||||||
|
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"website\""
|
||||||
}
|
}
|
||||||
|
|
||||||
internal {
|
nightly {
|
||||||
dimension 'distribution'
|
dimension 'distribution'
|
||||||
|
versionNameSuffix "-nightly-untagged-${getDateSuffix()}"
|
||||||
ext.websiteUpdateUrl = "null"
|
ext.websiteUpdateUrl = "null"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||||
|
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"nightly\""
|
||||||
}
|
}
|
||||||
|
|
||||||
study {
|
study {
|
||||||
@@ -251,6 +308,7 @@ android {
|
|||||||
ext.websiteUpdateUrl = "null"
|
ext.websiteUpdateUrl = "null"
|
||||||
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
buildConfigField "boolean", "PLAY_STORE_DISABLED", "false"
|
||||||
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
buildConfigField "String", "NOPLAY_UPDATE_URL", "$ext.websiteUpdateUrl"
|
||||||
|
buildConfigField "String", "BUILD_DISTRIBUTION_TYPE", "\"study\""
|
||||||
}
|
}
|
||||||
|
|
||||||
prod {
|
prod {
|
||||||
@@ -259,6 +317,7 @@ android {
|
|||||||
isDefault true
|
isDefault true
|
||||||
|
|
||||||
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
|
buildConfigField "String", "MOBILE_COIN_ENVIRONMENT", "\"mainnet\""
|
||||||
|
buildConfigField "String", "BUILD_ENVIRONMENT_TYPE", "\"Prod\""
|
||||||
}
|
}
|
||||||
|
|
||||||
staging {
|
staging {
|
||||||
@@ -266,33 +325,44 @@ android {
|
|||||||
|
|
||||||
applicationIdSuffix ".staging"
|
applicationIdSuffix ".staging"
|
||||||
|
|
||||||
buildConfigField "String", "SIGNAL_URL", "\"https://textsecure-service-staging.whispersystems.org\""
|
buildConfigField "String", "SIGNAL_URL", "\"https://chat.staging.signal.org\""
|
||||||
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
buildConfigField "String", "STORAGE_URL", "\"https://storage-staging.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
buildConfigField "String", "SIGNAL_CDN_URL", "\"https://cdn-staging.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
buildConfigField "String", "SIGNAL_CDN2_URL", "\"https://cdn2-staging.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
buildConfigField "String", "SIGNAL_CONTACT_DISCOVERY_URL", "\"https://api-staging.directory.signal.org\""
|
||||||
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
buildConfigField "String", "SIGNAL_KEY_BACKUP_URL", "\"https://api-staging.backup.signal.org\""
|
||||||
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
buildConfigField "String", "CDS_MRENCLAVE", "\"c98e00a4e3ff977a56afefe7362a27e4961e4f19e211febfbb19b897e6b80b15\""
|
||||||
buildConfigField "KbsEnclave", "KBS_ENCLAVE", "new KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave", "KBS_ENCLAVE", "new org.thoughtcrime.securesms.KbsEnclave(\"823a3b2c037ff0cbe305cc48928cfcc97c9ed4a8ca6d49af6f7d6981fb60a4e9\", " +
|
||||||
"\"51a56084c0b21c6b8f62b1bc792ec9bedac4c7c3964bb08ddcab868158c09982\", " +
|
"\"16b94ac6d2b7f7b9d72928f36d798dbb35ed32e7bb14c42b4301ad0344b46f29\", " +
|
||||||
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
"\"a3baab19ef6ce6f34ab9ebb25ba722725ae44a8872dc0ff08ad6d83a9489de87\")"
|
||||||
buildConfigField "KbsEnclave[]", "KBS_FALLBACKS", "new KbsEnclave[0]"
|
buildConfigField "org.thoughtcrime.securesms.KbsEnclave[]", "KBS_FALLBACKS", "new org.thoughtcrime.securesms.KbsEnclave[0]"
|
||||||
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+4WpMtIe8KC3wU5w3Inq3uNWVmoGtpKndsNfwJrCg0Hd9zmObhypUnSkfYn2ooMOOnBpfdanRtrvetZUayDMSC5iSRcXKpdls=\""
|
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", "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", "STRIPE_PUBLISHABLE_KEY", "\"pk_test_sngOd8FnXNkpce9nPXawKrJD00kIDngZkD\""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
android.applicationVariants.all { variant ->
|
android.applicationVariants.all { variant ->
|
||||||
variant.outputs.each { output ->
|
variant.outputs.each { output ->
|
||||||
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
if (output.baseName.contains('nightly')) {
|
||||||
def abiName = output.getFilter("ABI") ?: 'universal'
|
output.versionCodeOverride = canonicalVersionCode * postFixSize + 5
|
||||||
def postFix = abiPostFix.get(abiName, 0)
|
def tag = getCurrentGitTag()
|
||||||
|
if (tag != null && tag.length() > 0) {
|
||||||
|
output.versionNameOverride = tag
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.outputFileName = output.outputFileName.replace(".apk", "-${variant.versionName}.apk")
|
||||||
|
def abiName = output.getFilter("ABI") ?: 'universal'
|
||||||
|
def postFix = abiPostFix.get(abiName, 0)
|
||||||
|
|
||||||
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
if (postFix >= postFixSize) throw new AssertionError("postFix is too large")
|
||||||
|
|
||||||
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
output.versionCodeOverride = canonicalVersionCode * postFixSize + postFix
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,15 +370,15 @@ android {
|
|||||||
def distribution = variant.getFlavors().get(0).name
|
def distribution = variant.getFlavors().get(0).name
|
||||||
def environment = variant.getFlavors().get(1).name
|
def environment = variant.getFlavors().get(1).name
|
||||||
def buildType = variant.buildType.name
|
def buildType = variant.buildType.name
|
||||||
|
def fullName = distribution + environment.capitalize() + buildType.capitalize()
|
||||||
|
|
||||||
if (distribution == 'study' && buildType != 'perf' && buildType != 'mock') {
|
if (!selectableVariants.contains(fullName)) {
|
||||||
variant.setIgnore(true)
|
|
||||||
} else if (distribution != 'study' && buildType == 'mock') {
|
|
||||||
variant.setIgnore(true)
|
variant.setIgnore(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
lintOptions {
|
||||||
|
checkReleaseBuilds false
|
||||||
abortOnError true
|
abortOnError true
|
||||||
baseline file("lint-baseline.xml")
|
baseline file("lint-baseline.xml")
|
||||||
disable "LintError"
|
disable "LintError"
|
||||||
@@ -322,209 +392,163 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.fragment:fragment-ktx:1.2.5'
|
implementation libs.androidx.core.ktx
|
||||||
|
implementation libs.androidx.fragment.ktx
|
||||||
lintChecks project(':lintchecks')
|
lintChecks project(':lintchecks')
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
coreLibraryDesugaring libs.android.tools.desugar
|
||||||
|
|
||||||
implementation ('androidx.appcompat:appcompat:1.2.0') {
|
implementation (libs.androidx.appcompat) {
|
||||||
force = true
|
version {
|
||||||
|
strictly '1.2.0'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.1.0'
|
implementation libs.androidx.window
|
||||||
implementation 'com.google.android.material:material:1.3.0'
|
implementation libs.androidx.recyclerview
|
||||||
implementation 'androidx.legacy:legacy-support-v13:1.0.0'
|
implementation libs.material.material
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
implementation libs.androidx.legacy.support
|
||||||
implementation 'androidx.preference:preference:1.0.0'
|
implementation libs.androidx.cardview
|
||||||
implementation 'androidx.legacy:legacy-preference-v14:1.0.0'
|
implementation libs.androidx.preference
|
||||||
implementation 'androidx.gridlayout:gridlayout:1.0.0'
|
implementation libs.androidx.legacy.preference
|
||||||
implementation 'androidx.exifinterface:exifinterface:1.0.0'
|
implementation libs.androidx.gridlayout
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
implementation libs.androidx.exifinterface
|
||||||
implementation 'androidx.multidex:multidex:2.0.1'
|
implementation libs.androidx.constraintlayout
|
||||||
implementation 'androidx.navigation:navigation-fragment:2.1.0'
|
implementation libs.androidx.multidex
|
||||||
implementation 'androidx.navigation:navigation-ui:2.1.0'
|
implementation libs.androidx.navigation.fragment.ktx
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
|
implementation libs.androidx.navigation.ui.ktx
|
||||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-alpha05'
|
implementation libs.androidx.lifecycle.extensions
|
||||||
implementation 'androidx.lifecycle:lifecycle-common-java8:2.1.0'
|
implementation libs.androidx.lifecycle.viewmodel.savedstate
|
||||||
implementation "androidx.camera:camera-core:1.0.0-beta11"
|
implementation libs.androidx.lifecycle.common.java8
|
||||||
implementation "androidx.camera:camera-camera2:1.0.0-beta11"
|
implementation libs.androidx.lifecycle.reactivestreams.ktx
|
||||||
implementation "androidx.camera:camera-lifecycle:1.0.0-beta11"
|
implementation libs.androidx.camera.core
|
||||||
implementation "androidx.camera:camera-view:1.0.0-alpha18"
|
implementation libs.androidx.camera.camera2
|
||||||
implementation "androidx.concurrent:concurrent-futures:1.0.0"
|
implementation libs.androidx.camera.lifecycle
|
||||||
implementation "androidx.autofill:autofill:1.0.0"
|
implementation libs.androidx.camera.view
|
||||||
implementation "androidx.biometric:biometric:1.1.0"
|
implementation libs.androidx.concurrent.futures
|
||||||
|
implementation libs.androidx.autofill
|
||||||
|
implementation libs.androidx.biometric
|
||||||
|
implementation libs.androidx.sharetarget
|
||||||
|
|
||||||
implementation ('com.google.firebase:firebase-messaging:20.2.0') {
|
implementation (libs.firebase.messaging) {
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-core'
|
exclude group: 'com.google.firebase', module: 'firebase-core'
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
exclude group: 'com.google.firebase', module: 'firebase-analytics'
|
||||||
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
exclude group: 'com.google.firebase', module: 'firebase-measurement-connector'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'com.google.android.gms:play-services-maps:16.1.0'
|
implementation libs.google.play.services.maps
|
||||||
implementation 'com.google.android.gms:play-services-auth:16.0.1'
|
implementation libs.google.play.services.auth
|
||||||
|
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-core:2.9.1'
|
implementation libs.bundles.exoplayer
|
||||||
implementation 'com.google.android.exoplayer:exoplayer-ui:2.9.1'
|
|
||||||
implementation 'com.google.android.exoplayer:extension-mediasession:2.9.1'
|
|
||||||
|
|
||||||
implementation 'org.conscrypt:conscrypt-android:2.0.0'
|
implementation libs.conscrypt.android
|
||||||
implementation 'org.signal:aesgcmprovider:0.0.3'
|
implementation libs.signal.aesgcmprovider
|
||||||
|
|
||||||
implementation project(':libsignal-service')
|
implementation project(':libsignal-service')
|
||||||
implementation project(':paging')
|
implementation project(':paging')
|
||||||
implementation project(':core-util')
|
implementation project(':core-util')
|
||||||
|
implementation project(':glide-config')
|
||||||
implementation project(':video')
|
implementation project(':video')
|
||||||
implementation project(':device-transfer')
|
implementation project(':device-transfer')
|
||||||
|
implementation project(':image-editor')
|
||||||
|
implementation project(':donations')
|
||||||
|
|
||||||
implementation 'org.signal:zkgroup-android:0.7.0'
|
implementation libs.signal.client.android
|
||||||
implementation 'org.whispersystems:signal-client-android:0.8.0'
|
implementation libs.google.protobuf.javalite
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:3.10.0'
|
|
||||||
|
|
||||||
implementation('com.mobilecoin:android-sdk:1.0.0') {
|
implementation(libs.mobilecoin) {
|
||||||
exclude group: 'com.google.protobuf'
|
exclude group: 'com.google.protobuf'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'org.signal:argon2:13.1@aar'
|
implementation(libs.signal.argon2) {
|
||||||
|
artifact {
|
||||||
|
type = "aar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
implementation 'org.signal:ringrtc-android:2.10.1.1'
|
implementation libs.signal.ringrtc
|
||||||
|
|
||||||
implementation "me.leolin:ShortcutBadger:1.1.22"
|
implementation libs.leolin.shortcutbadger
|
||||||
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
|
implementation libs.emilsjolander.stickylistheaders
|
||||||
implementation 'com.jpardogo.materialtabstrip:library:1.0.9'
|
implementation libs.jpardogo.materialtabstrip
|
||||||
implementation 'org.apache.httpcomponents:httpclient-android:4.3.5'
|
implementation libs.apache.httpclient.android
|
||||||
implementation 'com.github.chrisbanes:PhotoView:2.1.3'
|
implementation libs.photoview
|
||||||
implementation 'com.github.bumptech.glide:glide:4.11.0'
|
implementation libs.glide.glide
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.11.0'
|
implementation libs.roundedimageview
|
||||||
kapt 'androidx.annotation:annotation:1.1.0'
|
implementation libs.materialish.progress
|
||||||
implementation 'com.makeramen:roundedimageview:2.1.0'
|
implementation libs.greenrobot.eventbus
|
||||||
implementation 'com.pnikosis:materialish-progress:1.5'
|
implementation libs.waitingdots
|
||||||
implementation 'org.greenrobot:eventbus:3.0.0'
|
implementation libs.floatingactionbutton
|
||||||
implementation 'pl.tajchert:waitingdots:0.1.0'
|
implementation libs.google.zxing.android.integration
|
||||||
implementation 'com.melnykov:floatingactionbutton:1.3.0'
|
implementation libs.time.duration.picker
|
||||||
implementation 'com.google.zxing:android-integration:3.1.0'
|
implementation libs.google.zxing.core
|
||||||
implementation 'mobi.upod:time-duration-picker:1.1.3'
|
implementation (libs.subsampling.scale.image.view) {
|
||||||
implementation 'com.amulyakhare:com.amulyakhare.textdrawable:1.0.1'
|
|
||||||
implementation 'com.google.zxing:core:3.2.1'
|
|
||||||
implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.6.0') {
|
|
||||||
exclude group: 'com.android.support', module: 'support-annotations'
|
exclude group: 'com.android.support', module: 'support-annotations'
|
||||||
}
|
}
|
||||||
implementation ('cn.carbswang.android:NumberPickerView:1.0.9') {
|
implementation (libs.numberpickerview) {
|
||||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||||
}
|
}
|
||||||
implementation ('com.tomergoldst.android:tooltips:1.0.6') {
|
implementation (libs.android.tooltips) {
|
||||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||||
}
|
}
|
||||||
implementation ('com.klinkerapps:android-smsmms:4.0.1') {
|
implementation (libs.android.smsmms) {
|
||||||
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
exclude group: 'com.squareup.okhttp', module: 'okhttp'
|
||||||
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
exclude group: 'com.squareup.okhttp', module: 'okhttp-urlconnection'
|
||||||
}
|
}
|
||||||
implementation 'com.annimon:stream:1.1.8'
|
implementation libs.stream
|
||||||
implementation ('com.takisoft.fix:colorpicker:0.9.1') {
|
implementation (libs.colorpicker) {
|
||||||
exclude group: 'com.android.support', module: 'appcompat-v7'
|
exclude group: 'com.android.support', module: 'appcompat-v7'
|
||||||
exclude group: 'com.android.support', module: 'recyclerview-v7'
|
exclude group: 'com.android.support', module: 'recyclerview-v7'
|
||||||
}
|
}
|
||||||
|
|
||||||
implementation 'com.airbnb.android:lottie:3.6.0'
|
implementation libs.lottie
|
||||||
|
|
||||||
implementation 'com.codewaves.stickyheadergrid:stickyheadergrid:0.9.4'
|
implementation libs.stickyheadergrid
|
||||||
implementation 'com.github.dmytrodanylyk.circular-progress-button:library:1.1.3-S2'
|
implementation libs.circular.progress.button
|
||||||
|
|
||||||
implementation "net.zetetic:android-database-sqlcipher:4.4.3"
|
implementation libs.signal.android.database.sqlcipher
|
||||||
implementation "androidx.sqlite:sqlite:2.1.0"
|
implementation libs.androidx.sqlite
|
||||||
|
|
||||||
implementation ('com.googlecode.ez-vcard:ez-vcard:0.9.11') {
|
implementation (libs.google.ez.vcard) {
|
||||||
exclude group: 'com.fasterxml.jackson.core'
|
exclude group: 'com.fasterxml.jackson.core'
|
||||||
exclude group: 'org.freemarker'
|
exclude group: 'org.freemarker'
|
||||||
}
|
}
|
||||||
implementation 'dnsjava:dnsjava:2.1.9'
|
implementation libs.dnsjava
|
||||||
|
|
||||||
flipperImplementation 'com.facebook.flipper:flipper:0.91.0'
|
flipperImplementation libs.facebook.flipper
|
||||||
flipperImplementation 'com.facebook.soloader:soloader:0.10.1'
|
flipperImplementation libs.facebook.soloader
|
||||||
|
flipperImplementation libs.square.leakcanary
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.12'
|
testImplementation testLibs.junit.junit
|
||||||
testImplementation 'org.assertj:assertj-core:3.11.1'
|
testImplementation testLibs.assertj.core
|
||||||
testImplementation 'org.mockito:mockito-core:2.8.9'
|
testImplementation testLibs.mockito.core
|
||||||
testImplementation 'org.powermock:powermock-api-mockito2:1.7.4'
|
testImplementation testLibs.powermock.api.mockito
|
||||||
testImplementation 'org.powermock:powermock-module-junit4:1.7.4'
|
testImplementation testLibs.powermock.module.junit4.core
|
||||||
testImplementation 'org.powermock:powermock-module-junit4-rule:1.7.4'
|
testImplementation testLibs.powermock.module.junit4.rule
|
||||||
testImplementation 'org.powermock:powermock-classloading-xstream:1.7.4'
|
testImplementation testLibs.powermock.classloading.xstream
|
||||||
|
|
||||||
testImplementation 'androidx.test:core:1.2.0'
|
testImplementation testLibs.androidx.test.core
|
||||||
testImplementation ('org.robolectric:robolectric:4.4') {
|
testImplementation (testLibs.robolectric.robolectric) {
|
||||||
exclude group: 'com.google.protobuf', module: 'protobuf-java'
|
exclude group: 'com.google.protobuf', module: 'protobuf-java'
|
||||||
}
|
}
|
||||||
testImplementation 'org.robolectric:shadows-multidex:4.4'
|
testImplementation testLibs.robolectric.shadows.multidex
|
||||||
testImplementation 'org.hamcrest:hamcrest:2.2'
|
testImplementation testLibs.hamcrest.hamcrest
|
||||||
|
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
|
testImplementation(testFixtures(project(":libsignal-service")))
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
androidTestImplementation testLibs.androidx.test.ext.junit
|
||||||
implementation "com.fasterxml.jackson.module:jackson-module-kotlin:2.12.0"
|
androidTestImplementation testLibs.espresso.core
|
||||||
}
|
|
||||||
|
|
||||||
dependencyVerification {
|
testImplementation testLibs.espresso.core
|
||||||
configuration = '(play|website)(Prod|Staging)(Debug|Release)RuntimeClasspath'
|
|
||||||
}
|
|
||||||
|
|
||||||
def assembleWebsiteDescriptor = { variant, file ->
|
implementation libs.kotlin.stdlib.jdk8
|
||||||
if (file.exists()) {
|
implementation libs.kotlin.reflect
|
||||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
implementation libs.jackson.module.kotlin
|
||||||
file.eachByte 4096, {bytes, size ->
|
|
||||||
md.update(bytes, 0, size);
|
|
||||||
}
|
|
||||||
|
|
||||||
String digest = md.digest().collect {String.format "%02x", it}.join();
|
implementation libs.rxjava3.rxandroid
|
||||||
String url = variant.productFlavors.get(0).ext.websiteUpdateUrl
|
implementation libs.rxjava3.rxkotlin
|
||||||
String apkName = file.getName()
|
implementation libs.rxdogtag
|
||||||
|
|
||||||
String descriptor = "{" +
|
androidTestUtil 'androidx.test:orchestrator:1.4.0'
|
||||||
"\"versionCode\" : ${canonicalVersionCode * postFixSize + abiPostFix['universal']}," +
|
|
||||||
"\"versionName\" : \"$canonicalVersionName\"," +
|
|
||||||
"\"sha256sum\" : \"$digest\"," +
|
|
||||||
"\"url\" : \"$url/$apkName\"" +
|
|
||||||
"}"
|
|
||||||
|
|
||||||
File descriptorFile = new File(file.getParent(), apkName.replace(".apk", ".json"))
|
|
||||||
|
|
||||||
descriptorFile.write(descriptor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def signProductionRelease = { variant ->
|
|
||||||
variant.outputs.collect { output ->
|
|
||||||
String apkName = output.outputFile.name
|
|
||||||
File inputFile = new File(output.outputFile.path)
|
|
||||||
File outputFile = new File(output.outputFile.parent, apkName.replace('-unsigned', ''))
|
|
||||||
|
|
||||||
new ApkSignerUtil('sun.security.pkcs11.SunPKCS11',
|
|
||||||
'pkcs11.config',
|
|
||||||
'PKCS11',
|
|
||||||
'file:pkcs11.password').calculateSignature(inputFile.getAbsolutePath(),
|
|
||||||
outputFile.getAbsolutePath())
|
|
||||||
|
|
||||||
inputFile.delete()
|
|
||||||
outputFile
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task signProductionPlayRelease {
|
|
||||||
doLast {
|
|
||||||
signProductionRelease(android.applicationVariants.find { (it.name == 'playProdRelease') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task signProductionInternalRelease {
|
|
||||||
doLast {
|
|
||||||
signProductionRelease(android.applicationVariants.find { (it.name == 'internalProdRelease') })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
task signProductionWebsiteRelease {
|
|
||||||
doLast {
|
|
||||||
def variant = android.applicationVariants.find { (it.name == 'websiteProdRelease') }
|
|
||||||
File signedRelease = signProductionRelease(variant).find { it.name.contains('universal') }
|
|
||||||
assembleWebsiteDescriptor(variant, signedRelease)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def getLastCommitTimestamp() {
|
def getLastCommitTimestamp() {
|
||||||
@@ -556,6 +580,27 @@ def getGitHash() {
|
|||||||
return stdout.toString().trim()
|
return stdout.toString().trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def getCurrentGitTag() {
|
||||||
|
if (!(new File('.git').exists())) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
def stdout = new ByteArrayOutputStream()
|
||||||
|
exec {
|
||||||
|
commandLine 'git', 'tag', '--points-at', 'HEAD'
|
||||||
|
standardOutput = stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
def output = stdout.toString().trim()
|
||||||
|
|
||||||
|
if (output != null && output.size() > 0) {
|
||||||
|
def tags = output.split('\n').toList()
|
||||||
|
return tags.stream().filter(t -> t.contains('nightly')).findFirst().orElse(tags.get(0))
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tasks.withType(Test) {
|
tasks.withType(Test) {
|
||||||
testLogging {
|
testLogging {
|
||||||
events "failed"
|
events "failed"
|
||||||
@@ -576,3 +621,9 @@ def loadKeystoreProperties(filename) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def getDateSuffix() {
|
||||||
|
def date = new Date()
|
||||||
|
def formattedDate = date.format('yyyy-MM-dd-HH:mm')
|
||||||
|
return formattedDate
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
-keep class org.sqlite.database.** { *; }
|
-keep class org.sqlite.database.** { *; }
|
||||||
|
|
||||||
-keep class net.sqlcipher.** { *; }
|
-keep class net.sqlcipher.** { *; }
|
||||||
-dontwarn net.sqlcipher.**
|
-dontwarn net.sqlcipher.**
|
||||||
|
|
||||||
|
-keep class net.zetetic.** { *; }
|
||||||
|
-dontwarn net.zetetic.**
|
||||||
|
|||||||
@@ -9,3 +9,5 @@
|
|||||||
|
|
||||||
# Protobuf lite
|
# Protobuf lite
|
||||||
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
-keep class * extends com.google.protobuf.GeneratedMessageLite { *; }
|
||||||
|
|
||||||
|
-keep class androidx.window.** { *; }
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
package org.thoughtcrime.securesms.database
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.AccountValues
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.KeyValueStore
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.MockKeyValuePersistentStorage
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||||
|
import org.whispersystems.libsignal.util.guava.Optional
|
||||||
|
import org.whispersystems.signalservice.api.push.ACI
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class RecipientDatabaseTest {
|
||||||
|
|
||||||
|
private lateinit var recipientDatabase: RecipientDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
recipientDatabase = SignalDatabase.recipients
|
||||||
|
ensureDbEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// If both the ACI and E164 map to no one
|
||||||
|
// ==============================================================
|
||||||
|
|
||||||
|
/** If all you have is an ACI, you can just store that, regardless of trust level. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly_highTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(ACI_A, recipient.requireAci())
|
||||||
|
assertFalse(recipient.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If all you have is an ACI, you can just store that, regardless of trust level. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciOnly_lowTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, false)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(ACI_A, recipient.requireAci())
|
||||||
|
assertFalse(recipient.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only_highTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(E164_A, recipient.requireE164())
|
||||||
|
assertFalse(recipient.hasAci())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If all you have is an E164, you can just store that, regardless of trust level. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_e164Only_lowTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, false)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(E164_A, recipient.requireE164())
|
||||||
|
assertFalse(recipient.hasAci())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With high trust, you can associate an ACI-e164 pair. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164_highTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(ACI_A, recipient.requireAci())
|
||||||
|
assertEquals(E164_A, recipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With low trust, you cannot associate an ACI-e164 pair, and therefore can only store the ACI. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciAndE164MapToNoOne_aciAndE164_lowTrust() {
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertEquals(ACI_A, recipient.requireAci())
|
||||||
|
assertFalse(recipient.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// If the ACI maps to an existing user, but the E164 doesn't
|
||||||
|
// ==============================================================
|
||||||
|
|
||||||
|
/** With high trust, you can associate an e164 with an existing ACI. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_highTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With low trust, you cannot associate an ACI-e164 pair, and therefore cannot store the e164. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_lowTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Basically the ‘change number’ case. High trust lets you update the existing user. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_highTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Low trust means you can’t update the underlying data, but you also don’t need to create any new rows. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_aciMapsToExistingUserButE164DoesNot_aciAndE164_2_lowTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, false)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// If the E164 maps to an existing user, but the ACI doesn't
|
||||||
|
// ==============================================================
|
||||||
|
|
||||||
|
/** With high trust, you can associate an e164 with an existing ACI. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_highTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With low trust, you cannot associate an ACI-e164 pair, and therefore need to create a new person with just the ACI. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_lowTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||||
|
assertNotEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
|
||||||
|
val existingRecipient = Recipient.resolved(existingId)
|
||||||
|
assertEquals(E164_A, existingRecipient.requireE164())
|
||||||
|
assertFalse(existingRecipient.hasAci())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. But high trust lets us take the e164 from the current holder. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_highTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||||
|
assertNotEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
|
||||||
|
val existingRecipient = Recipient.resolved(existingId)
|
||||||
|
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||||
|
assertFalse(existingRecipient.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We never change the ACI of an existing row. New ACI = new person, regardless of trust. And low trust means we can’t take the e164. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_aciAndE164_2_lowTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, false)
|
||||||
|
assertNotEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
|
||||||
|
val existingRecipient = Recipient.resolved(existingId)
|
||||||
|
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, existingRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_e164MapsToExistingUserButAciDoesNot_e164BelongsToLocalUser_highTrust() {
|
||||||
|
val dataSet = KeyValueDataSet().apply {
|
||||||
|
putString(AccountValues.KEY_E164, E164_A)
|
||||||
|
putString(AccountValues.KEY_ACI, ACI_A.toString())
|
||||||
|
}
|
||||||
|
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||||
|
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||||
|
assertNotEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_B, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
|
||||||
|
val existingRecipient = Recipient.resolved(existingId)
|
||||||
|
assertEquals(ACI_A, existingRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, existingRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// If both the ACI and E164 map to an existing user
|
||||||
|
// ==============================================================
|
||||||
|
|
||||||
|
/** Regardless of trust, if your ACI and e164 match, you’re good. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_highTrust() {
|
||||||
|
val existingId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** High trust lets you merge two different users into one. You should prefer the ACI user. Not shown: merging threads, dropping e164 sessions, etc. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_merge_highTrust() {
|
||||||
|
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||||
|
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingAciId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
|
||||||
|
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||||
|
assertEquals(retrievedId, existingE164Recipient.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Low trust means you can’t merge. If you’re retrieving a user from the table with this data, prefer the ACI one. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_lowTrust() {
|
||||||
|
val existingAciId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||||
|
val existingE164Id: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||||
|
assertEquals(existingAciId, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
|
||||||
|
val existingE164Recipient = Recipient.resolved(existingE164Id)
|
||||||
|
assertEquals(E164_A, existingE164Recipient.requireE164())
|
||||||
|
assertFalse(existingE164Recipient.hasAci())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another high trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_highTrust() {
|
||||||
|
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||||
|
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId1, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
|
||||||
|
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||||
|
assertEquals(ACI_B, existingRecipient2.requireAci())
|
||||||
|
assertFalse(existingRecipient2.hasE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Another low trust case. No new rules here, just a more complex scenario to show how different rules interact. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_complex_lowTrust() {
|
||||||
|
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||||
|
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, false)
|
||||||
|
assertEquals(existingId1, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_B, retrievedRecipient.requireE164())
|
||||||
|
|
||||||
|
val existingRecipient2 = Recipient.resolved(existingId2)
|
||||||
|
assertEquals(ACI_B, existingRecipient2.requireAci())
|
||||||
|
assertEquals(E164_A, existingRecipient2.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another high trust case that results in a merge. Nothing strictly new here, but this case is called out because it’s a merge but *also* an E164 change,
|
||||||
|
* which clients may need to know for UX purposes.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_aciAndE164_mergeAndPhoneNumberChange_highTrust() {
|
||||||
|
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_B, true)
|
||||||
|
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(null, E164_A, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId1, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertEquals(E164_A, retrievedRecipient.requireE164())
|
||||||
|
|
||||||
|
assertFalse(recipientDatabase.getByE164(E164_B).isPresent)
|
||||||
|
|
||||||
|
val recipientWithId2 = Recipient.resolved(existingId2)
|
||||||
|
assertEquals(retrievedId, recipientWithId2.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** We never want to remove the e164 of our own contact entry. So basically treat this as a low-trust case, and leave the e164 alone. */
|
||||||
|
@Test
|
||||||
|
fun getAndPossiblyMerge_bothAciAndE164MapToExistingUser_e164BelongsToLocalUser() {
|
||||||
|
val dataSet = KeyValueDataSet().apply {
|
||||||
|
putString(AccountValues.KEY_E164, E164_A)
|
||||||
|
putString(AccountValues.KEY_ACI, ACI_B.toString())
|
||||||
|
}
|
||||||
|
SignalStore.inject(KeyValueStore(MockKeyValuePersistentStorage.withDataSet(dataSet)))
|
||||||
|
|
||||||
|
val existingId1: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_B, E164_A, true)
|
||||||
|
val existingId2: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, null, true)
|
||||||
|
|
||||||
|
val retrievedId: RecipientId = recipientDatabase.getAndPossiblyMerge(ACI_A, E164_A, true)
|
||||||
|
assertEquals(existingId2, retrievedId)
|
||||||
|
|
||||||
|
val retrievedRecipient = Recipient.resolved(retrievedId)
|
||||||
|
assertEquals(ACI_A, retrievedRecipient.requireAci())
|
||||||
|
assertFalse(retrievedRecipient.hasE164())
|
||||||
|
|
||||||
|
val recipientWithId1 = Recipient.resolved(existingId1)
|
||||||
|
assertEquals(ACI_B, recipientWithId1.requireAci())
|
||||||
|
assertEquals(E164_A, recipientWithId1.requireE164())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================================================
|
||||||
|
// Misc
|
||||||
|
// ==============================================================
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createByE164SanityCheck() {
|
||||||
|
// GIVEN one recipient
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
|
||||||
|
// WHEN I retrieve one by E164
|
||||||
|
val possible: Optional<RecipientId> = recipientDatabase.getByE164(E164_A)
|
||||||
|
|
||||||
|
// THEN I get it back, and it has the properties I expect
|
||||||
|
assertTrue(possible.isPresent)
|
||||||
|
assertEquals(recipientId, possible.get())
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertTrue(recipient.e164.isPresent)
|
||||||
|
assertEquals(E164_A, recipient.e164.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createByUuidSanityCheck() {
|
||||||
|
// GIVEN one recipient
|
||||||
|
val recipientId: RecipientId = recipientDatabase.getOrInsertFromAci(ACI_A)
|
||||||
|
|
||||||
|
// WHEN I retrieve one by UUID
|
||||||
|
val possible: Optional<RecipientId> = recipientDatabase.getByAci(ACI_A)
|
||||||
|
|
||||||
|
// THEN I get it back, and it has the properties I expect
|
||||||
|
assertTrue(possible.isPresent)
|
||||||
|
assertEquals(recipientId, possible.get())
|
||||||
|
|
||||||
|
val recipient = Recipient.resolved(recipientId)
|
||||||
|
assertTrue(recipient.aci.isPresent)
|
||||||
|
assertEquals(ACI_A, recipient.aci.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun getAndPossiblyMerge_noArgs_invalid() {
|
||||||
|
recipientDatabase.getAndPossiblyMerge(null, null, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureDbEmpty() {
|
||||||
|
SignalDatabase.rawDatabase.rawQuery("SELECT COUNT(*) FROM ${RecipientDatabase.TABLE_NAME}", null).use { cursor ->
|
||||||
|
assertTrue(cursor.moveToFirst())
|
||||||
|
assertEquals(0, cursor.getLong(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
const val E164_A = "+12221234567"
|
||||||
|
const val E164_B = "+13331234567"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
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.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.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
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
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.getOrInsertFromAci(ACI_A)
|
||||||
|
val recipientIdE164: RecipientId = recipientDatabase.getOrInsertFromE164(E164_A)
|
||||||
|
val recipientIdAciB: RecipientId = recipientDatabase.getOrInsertFromAci(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(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.requireAci())
|
||||||
|
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(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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package org.thoughtcrime.securesms.lock;
|
package org.thoughtcrime.securesms.lock;
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
import org.thoughtcrime.securesms.util.Hex;
|
import org.thoughtcrime.securesms.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;
|
||||||
@@ -12,6 +15,7 @@ import static org.junit.Assert.assertArrayEquals;
|
|||||||
import static org.junit.Assert.assertEquals;
|
import static org.junit.Assert.assertEquals;
|
||||||
import static org.junit.Assert.assertTrue;
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
public final class PinHashing_hashPin_Test {
|
public final class PinHashing_hashPin_Test {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
|
||||||
import com.facebook.flipper.core.FlipperClient;
|
|
||||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
|
||||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
|
||||||
import com.facebook.soloader.SoLoader;
|
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.FlipperSqlCipherAdapter;
|
|
||||||
|
|
||||||
public class FlipperApplicationContext extends ApplicationContext {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
|
|
||||||
SoLoader.init(this, false);
|
|
||||||
|
|
||||||
FlipperClient client = AndroidFlipperClient.getInstance(this);
|
|
||||||
client.addPlugin(new InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()));
|
|
||||||
client.addPlugin(new DatabasesFlipperPlugin(new FlipperSqlCipherAdapter(this)));
|
|
||||||
client.addPlugin(new SharedPreferencesFlipperPlugin(this));
|
|
||||||
client.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import com.facebook.flipper.android.AndroidFlipperClient
|
||||||
|
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin
|
||||||
|
import com.facebook.flipper.plugins.inspector.DescriptorMapping
|
||||||
|
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin
|
||||||
|
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin
|
||||||
|
import com.facebook.soloader.SoLoader
|
||||||
|
import leakcanary.LeakCanary
|
||||||
|
import org.thoughtcrime.securesms.database.FlipperSqlCipherAdapter
|
||||||
|
import shark.AndroidReferenceMatchers
|
||||||
|
|
||||||
|
class FlipperApplicationContext : ApplicationContext() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
SoLoader.init(this, false)
|
||||||
|
|
||||||
|
val client = AndroidFlipperClient.getInstance(this)
|
||||||
|
client.addPlugin(InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()))
|
||||||
|
client.addPlugin(DatabasesFlipperPlugin(FlipperSqlCipherAdapter(this)))
|
||||||
|
client.addPlugin(SharedPreferencesFlipperPlugin(this))
|
||||||
|
client.start()
|
||||||
|
|
||||||
|
LeakCanary.config = LeakCanary.config.copy(
|
||||||
|
referenceMatchers = AndroidReferenceMatchers.appDefaults +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "android.service.media.MediaBrowserService\$ServiceBinder",
|
||||||
|
fieldName = "this\$0"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "androidx.media.MediaBrowserServiceCompat\$MediaBrowserServiceImplApi26\$MediaBrowserServiceApi26",
|
||||||
|
fieldName = "mBase"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "android.support.v4.media.MediaBrowserCompat",
|
||||||
|
fieldName = "mImpl"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "android.support.v4.media.session.MediaControllerCompat",
|
||||||
|
fieldName = "mToken"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "android.support.v4.media.session.MediaControllerCompat",
|
||||||
|
fieldName = "mImpl"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackService",
|
||||||
|
fieldName = "mApplication"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "org.thoughtcrime.securesms.service.GenericForegroundService\$LocalBinder",
|
||||||
|
fieldName = "this\$0"
|
||||||
|
) +
|
||||||
|
AndroidReferenceMatchers.ignoredInstanceField(
|
||||||
|
className = "org.thoughtcrime.securesms.contacts.ContactsSyncAdapter",
|
||||||
|
fieldName = "mContext"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,12 +11,11 @@ import androidx.annotation.Nullable;
|
|||||||
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
|
import com.facebook.flipper.plugins.databases.DatabaseDescriptor;
|
||||||
import com.facebook.flipper.plugins.databases.DatabaseDriver;
|
import com.facebook.flipper.plugins.databases.DatabaseDriver;
|
||||||
|
|
||||||
import net.sqlcipher.DatabaseUtils;
|
import net.zetetic.database.DatabaseUtils;
|
||||||
import net.sqlcipher.database.SQLiteDatabase;
|
import net.zetetic.database.sqlcipher.SQLiteDatabase;
|
||||||
import net.sqlcipher.database.SQLiteStatement;
|
import net.zetetic.database.sqlcipher.SQLiteStatement;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
|
||||||
import org.thoughtcrime.securesms.util.Hex;
|
import org.thoughtcrime.securesms.util.Hex;
|
||||||
|
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
@@ -43,18 +42,17 @@ public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdap
|
|||||||
@Override
|
@Override
|
||||||
public List<Descriptor> getDatabases() {
|
public List<Descriptor> getDatabases() {
|
||||||
try {
|
try {
|
||||||
Field databaseHelperField = DatabaseFactory.class.getDeclaredField("databaseHelper");
|
SignalDatabaseOpenHelper mainOpenHelper = Objects.requireNonNull(SignalDatabase.getInstance());
|
||||||
databaseHelperField.setAccessible(true);
|
SignalDatabaseOpenHelper keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
|
||||||
|
SignalDatabaseOpenHelper megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
|
||||||
SignalDatabase mainOpenHelper = Objects.requireNonNull((SQLCipherOpenHelper) databaseHelperField.get(DatabaseFactory.getInstance(getContext())));
|
SignalDatabaseOpenHelper jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
|
||||||
SignalDatabase keyValueOpenHelper = KeyValueDatabase.getInstance((Application) getContext());
|
SignalDatabaseOpenHelper metricsOpenHelper = LocalMetricsDatabase.getInstance((Application) getContext());
|
||||||
SignalDatabase megaphoneOpenHelper = MegaphoneDatabase.getInstance((Application) getContext());
|
|
||||||
SignalDatabase jobManagerOpenHelper = JobDatabase.getInstance((Application) getContext());
|
|
||||||
|
|
||||||
return Arrays.asList(new Descriptor(mainOpenHelper),
|
return Arrays.asList(new Descriptor(mainOpenHelper),
|
||||||
new Descriptor(keyValueOpenHelper),
|
new Descriptor(keyValueOpenHelper),
|
||||||
new Descriptor(megaphoneOpenHelper),
|
new Descriptor(megaphoneOpenHelper),
|
||||||
new Descriptor(jobManagerOpenHelper));
|
new Descriptor(jobManagerOpenHelper),
|
||||||
|
new Descriptor(metricsOpenHelper));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.i(TAG, "Unable to use reflection to access raw database.", e);
|
Log.i(TAG, "Unable to use reflection to access raw database.", e);
|
||||||
}
|
}
|
||||||
@@ -251,9 +249,9 @@ public class FlipperSqlCipherAdapter extends DatabaseDriver<FlipperSqlCipherAdap
|
|||||||
}
|
}
|
||||||
|
|
||||||
static class Descriptor implements DatabaseDescriptor {
|
static class Descriptor implements DatabaseDescriptor {
|
||||||
private final SignalDatabase sqlCipherOpenHelper;
|
private final SignalDatabaseOpenHelper sqlCipherOpenHelper;
|
||||||
|
|
||||||
Descriptor(@NonNull SignalDatabase sqlCipherOpenHelper) {
|
Descriptor(@NonNull SignalDatabaseOpenHelper sqlCipherOpenHelper) {
|
||||||
this.sqlCipherOpenHelper = sqlCipherOpenHelper;
|
this.sqlCipherOpenHelper = sqlCipherOpenHelper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,14 +96,22 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
tools:replace="android:allowBackup"
|
tools:replace="android:allowBackup"
|
||||||
|
android:resizeableActivity="true"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:theme="@style/TextSecure.LightTheme"
|
android:theme="@style/TextSecure.LightTheme"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.gms.wallet.api.enabled"
|
||||||
|
android:value="true" />
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.geo.API_KEY"
|
android:name="com.google.android.geo.API_KEY"
|
||||||
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
android:value="AIzaSyCSx9xea86GwDKGznCAULE9Y5a8b-TfN9U"/>
|
||||||
|
|
||||||
|
<meta-data android:name="android.supports_size_changes"
|
||||||
|
android:value="true" />
|
||||||
|
|
||||||
<meta-data android:name="com.google.android.gms.version"
|
<meta-data android:name="com.google.android.gms.version"
|
||||||
android:value="@integer/google_play_services_version" />
|
android:value="@integer/google_play_services_version" />
|
||||||
|
|
||||||
@@ -117,16 +125,15 @@
|
|||||||
<activity android:name=".WebRtcCallActivity"
|
<activity android:name=".WebRtcCallActivity"
|
||||||
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
|
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:supportsPictureInPicture="true"
|
android:supportsPictureInPicture="true"
|
||||||
android:windowSoftInputMode="stateAlwaysHidden"
|
android:windowSoftInputMode="stateAlwaysHidden"
|
||||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
|
||||||
android:taskAffinity=".calling"
|
android:taskAffinity=".calling"
|
||||||
|
android:resizeableActivity="true"
|
||||||
android:launchMode="singleTask"/>
|
android:launchMode="singleTask"/>
|
||||||
|
|
||||||
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
|
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
|
||||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
@@ -173,7 +180,6 @@
|
|||||||
<activity android:name=".sharing.ShareActivity"
|
<activity android:name=".sharing.ShareActivity"
|
||||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:launchMode="singleTask"
|
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="stateHidden"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||||
@@ -198,7 +204,7 @@
|
|||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.service.chooser.chooser_target_service"
|
android:name="android.service.chooser.chooser_target_service"
|
||||||
android:value=".service.DirectShareService" />
|
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
@@ -240,6 +246,9 @@
|
|||||||
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
|
<meta-data android:name="com.sec.minimode.icon.landscape.normal"
|
||||||
android:resource="@mipmap/ic_launcher" />
|
android:resource="@mipmap/ic_launcher" />
|
||||||
|
|
||||||
|
<meta-data android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
|
||||||
</activity-alias>
|
</activity-alias>
|
||||||
|
|
||||||
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
<activity android:name=".deeplinks.DeepLinkEntryActivity"
|
||||||
@@ -272,6 +281,16 @@
|
|||||||
<data android:scheme="sgnl"
|
<data android:scheme="sgnl"
|
||||||
android:host="signal.tube" />
|
android:host="signal.tube" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https"
|
||||||
|
android:host="signal.me" />
|
||||||
|
<data android:scheme="sgnl"
|
||||||
|
android:host="signal.me" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity android:name=".conversation.ConversationActivity"
|
<activity android:name=".conversation.ConversationActivity"
|
||||||
@@ -302,20 +321,13 @@
|
|||||||
<activity android:name=".messagedetails.MessageDetailsActivity"
|
<activity android:name=".messagedetails.MessageDetailsActivity"
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="stateHidden"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar" />
|
||||||
|
|
||||||
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
|
<activity android:name=".groups.ui.invitesandrequests.ManagePendingAndRequestingMembersActivity"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||||
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
android:theme="@style/Theme.Signal.DayNight.NoActionBar" />
|
||||||
|
|
||||||
<activity android:name=".groups.ui.managegroup.ManageGroupActivity"
|
|
||||||
android:windowSoftInputMode="stateAlwaysHidden"
|
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
|
||||||
|
|
||||||
<activity android:name=".recipients.ui.managerecipient.ManageRecipientActivity"
|
|
||||||
android:windowSoftInputMode="stateAlwaysHidden"
|
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
|
||||||
|
|
||||||
<activity android:name=".recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity"
|
<activity android:name=".recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
@@ -327,7 +339,7 @@
|
|||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".migrations.ApplicationMigrationActivity"
|
<activity android:name=".migrations.ApplicationMigrationActivity"
|
||||||
android:theme="@style/NoAnimation.Theme.AppCompat.Light.DarkActionBar"
|
android:theme="@style/Theme.Signal.DayNight.NoActionBar"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
@@ -358,17 +370,18 @@
|
|||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="stateHidden"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".mediasend.MediaSendActivity"
|
<activity android:name=".mediasend.v2.MediaSelectionActivity"
|
||||||
android:theme="@style/TextSecure.FullScreenMedia"
|
android:theme="@style/TextSecure.FullScreenMedia"
|
||||||
android:windowSoftInputMode="stateHidden"
|
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".PassphraseChangeActivity"
|
<activity android:name=".PassphraseChangeActivity"
|
||||||
android:label="@string/AndroidManifest__change_passphrase"
|
android:label="@string/AndroidManifest__change_passphrase"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".VerifyIdentityActivity"
|
<activity android:name=".verify.VerifyIdentityActivity"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
<activity android:name=".components.settings.app.AppSettingsActivity"
|
<activity android:name=".components.settings.app.AppSettingsActivity"
|
||||||
@@ -381,6 +394,17 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity android:name=".components.settings.app.changenumber.ChangeNumberLockActivity"
|
||||||
|
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
|
||||||
|
|
||||||
|
<activity android:name=".components.settings.conversation.ConversationSettingsActivity"
|
||||||
|
android:configChanges="touchscreen|keyboard|keyboardHidden|screenLayout|screenSize"
|
||||||
|
android:theme="@style/Signal.DayNight.ConversationSettings"
|
||||||
|
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"
|
||||||
android:windowSoftInputMode="stateAlwaysHidden">
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
@@ -479,6 +503,7 @@
|
|||||||
|
|
||||||
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
|
<activity android:name="org.thoughtcrime.securesms.webrtc.VoiceCallShare"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
|
android:permission="android.permission.CALL_PHONE"
|
||||||
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
android:theme="@style/NoAnimation.Theme.BlackScreen"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize">
|
||||||
@@ -652,13 +677,6 @@
|
|||||||
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
|
<meta-data android:name="android.provider.CONTACTS_STRUCTURE" android:resource="@xml/contactsformat" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service android:name=".service.DirectShareService"
|
|
||||||
android:permission="android.permission.BIND_CHOOSER_TARGET_SERVICE">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.service.chooser.ChooserTargetService" />
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
|
||||||
|
|
||||||
<service android:name=".service.GenericForegroundService"/>
|
<service android:name=".service.GenericForegroundService"/>
|
||||||
|
|
||||||
<service android:name=".gcm.FcmFetchService" />
|
<service android:name=".gcm.FcmFetchService" />
|
||||||
@@ -752,22 +770,6 @@
|
|||||||
|
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<provider android:name=".database.DatabaseContentProviders$Conversation"
|
|
||||||
android:authorities="${applicationId}.database.conversation"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider android:name=".database.DatabaseContentProviders$Attachment"
|
|
||||||
android:authorities="${applicationId}.database.attachment"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider android:name=".database.DatabaseContentProviders$Sticker"
|
|
||||||
android:authorities="${applicationId}.database.sticker"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<provider android:name=".database.DatabaseContentProviders$StickerPack"
|
|
||||||
android:authorities="${applicationId}.database.stickerpack"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<receiver android:name=".service.BootReceiver">
|
<receiver android:name=".service.BootReceiver">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 91 KiB |
@@ -60,6 +60,7 @@ import androidx.camera.core.impl.LensFacingConverter;
|
|||||||
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
||||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||||
import androidx.camera.core.impl.utils.futures.Futures;
|
import androidx.camera.core.impl.utils.futures.Futures;
|
||||||
|
import androidx.core.util.Consumer;
|
||||||
import androidx.lifecycle.LifecycleOwner;
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
|
|
||||||
@@ -130,6 +131,11 @@ public final class SignalCameraView extends FrameLayout {
|
|||||||
// For accessibility event
|
// For accessibility event
|
||||||
private MotionEvent mUpEvent;
|
private MotionEvent mUpEvent;
|
||||||
|
|
||||||
|
// BEGIN Custom Signal Code Block
|
||||||
|
private Consumer<Throwable> errorConsumer;
|
||||||
|
private Throwable pendingError;
|
||||||
|
// END Custom Signal Code Block
|
||||||
|
|
||||||
public SignalCameraView(@NonNull Context context) {
|
public SignalCameraView(@NonNull Context context) {
|
||||||
this(context, null);
|
this(context, null);
|
||||||
}
|
}
|
||||||
@@ -167,14 +173,32 @@ public final class SignalCameraView extends FrameLayout {
|
|||||||
* androidx.lifecycle.Lifecycle.State#DESTROYED} state.
|
* androidx.lifecycle.Lifecycle.State#DESTROYED} state.
|
||||||
* @throws IllegalStateException if camera permissions are not granted.
|
* @throws IllegalStateException if camera permissions are not granted.
|
||||||
*/
|
*/
|
||||||
|
// BEGIN Custom Signal Code Block
|
||||||
|
|
||||||
@RequiresPermission(permission.CAMERA)
|
@RequiresPermission(permission.CAMERA)
|
||||||
public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner) {
|
public void bindToLifecycle(@NonNull LifecycleOwner lifecycleOwner, Consumer<Throwable> errorConsumer) {
|
||||||
mCameraModule.bindToLifecycle(lifecycleOwner);
|
mCameraModule.bindToLifecycle(lifecycleOwner);
|
||||||
|
this.errorConsumer = errorConsumer;
|
||||||
|
if (pendingError != null) {
|
||||||
|
errorConsumer.accept(pendingError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// END Custom Signal Code Block
|
||||||
|
|
||||||
|
|
||||||
private void init(Context context, @Nullable AttributeSet attrs) {
|
private void init(Context context, @Nullable AttributeSet attrs) {
|
||||||
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
|
addView(mPreviewView = new PreviewView(getContext()), 0 /* view position */);
|
||||||
mCameraModule = new SignalCameraXModule(this);
|
|
||||||
|
// Begin custom signal code block
|
||||||
|
mPreviewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE);
|
||||||
|
mCameraModule = new SignalCameraXModule(this, error -> {
|
||||||
|
if (errorConsumer != null) {
|
||||||
|
errorConsumer.accept(error);
|
||||||
|
} else {
|
||||||
|
pendingError = error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// End custom signal code block
|
||||||
|
|
||||||
if (attrs != null) {
|
if (attrs != null) {
|
||||||
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
|
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CameraView);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import androidx.camera.core.impl.utils.executor.CameraXExecutors;
|
|||||||
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
import androidx.camera.core.impl.utils.futures.FutureCallback;
|
||||||
import androidx.camera.core.impl.utils.futures.Futures;
|
import androidx.camera.core.impl.utils.futures.Futures;
|
||||||
import androidx.camera.lifecycle.ProcessCameraProvider;
|
import androidx.camera.lifecycle.ProcessCameraProvider;
|
||||||
|
import androidx.core.util.Consumer;
|
||||||
import androidx.core.util.Preconditions;
|
import androidx.core.util.Preconditions;
|
||||||
import androidx.lifecycle.Lifecycle;
|
import androidx.lifecycle.Lifecycle;
|
||||||
import androidx.lifecycle.LifecycleObserver;
|
import androidx.lifecycle.LifecycleObserver;
|
||||||
@@ -123,7 +124,9 @@ final class SignalCameraXModule {
|
|||||||
@Nullable
|
@Nullable
|
||||||
ProcessCameraProvider mCameraProvider;
|
ProcessCameraProvider mCameraProvider;
|
||||||
|
|
||||||
SignalCameraXModule(SignalCameraView view) {
|
// BEGIN Custom Signal Code Block
|
||||||
|
SignalCameraXModule(SignalCameraView view, Consumer<Throwable> errorConsumer) {
|
||||||
|
// END Custom Signal Code Block
|
||||||
mCameraView = view;
|
mCameraView = view;
|
||||||
|
|
||||||
Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()),
|
Futures.addCallback(ProcessCameraProvider.getInstance(view.getContext()),
|
||||||
@@ -141,7 +144,9 @@ final class SignalCameraXModule {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(Throwable t) {
|
public void onFailure(Throwable t) {
|
||||||
throw new RuntimeException("CameraX failed to initialize.", t);
|
// BEGIN Custom Signal Code Block
|
||||||
|
errorConsumer.accept(t);
|
||||||
|
// END Custom Signal Code Block
|
||||||
}
|
}
|
||||||
}, CameraXExecutors.mainThreadExecutor());
|
}, CameraXExecutors.mainThreadExecutor());
|
||||||
|
|
||||||
@@ -222,17 +227,10 @@ final class SignalCameraXModule {
|
|||||||
// End Signal Custom Code Block
|
// End Signal Custom Code Block
|
||||||
|
|
||||||
Rational targetAspectRatio;
|
Rational targetAspectRatio;
|
||||||
if (getCaptureMode() == SignalCameraView.CaptureMode.IMAGE) {
|
// Begin Signal Custom Code Block
|
||||||
// Begin Signal Custom Code Block
|
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
|
||||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_4_3, isDisplayPortrait));
|
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
|
||||||
// End Signal Custom Code Block
|
// End Signal Custom Code Block
|
||||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_3_4 : ASPECT_RATIO_4_3;
|
|
||||||
} else {
|
|
||||||
// Begin Signal Custom Code Block
|
|
||||||
mImageCaptureBuilder.setTargetResolution(CameraXUtil.buildResolutionForRatio(resolution, ASPECT_RATIO_16_9, isDisplayPortrait));
|
|
||||||
// End Signal Custom Code Block
|
|
||||||
targetAspectRatio = isDisplayPortrait ? ASPECT_RATIO_9_16 : ASPECT_RATIO_16_9;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Begin Signal Custom Code Block
|
// Begin Signal Custom Code Block
|
||||||
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
|
mImageCaptureBuilder.setCaptureMode(CameraXUtil.getOptimalCaptureMode());
|
||||||
@@ -512,7 +510,12 @@ final class SignalCameraXModule {
|
|||||||
return rotationDegrees;
|
return rotationDegrees;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeExperimentalUsageError")
|
||||||
public void invalidateView() {
|
public void invalidateView() {
|
||||||
|
if (mPreview != null) {
|
||||||
|
mPreview.setTargetRotation(getDisplaySurfaceRotation()); // Fixes issue #10940 (rotation not updated on phones using "Legacy API")
|
||||||
|
}
|
||||||
|
|
||||||
updateViewInfo();
|
updateViewInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,15 +8,17 @@ public final class AppCapabilities {
|
|||||||
private AppCapabilities() {
|
private AppCapabilities() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final boolean UUID_CAPABLE = false;
|
private static final boolean UUID_CAPABLE = false;
|
||||||
private static final boolean GV2_CAPABLE = true;
|
private static final boolean GV2_CAPABLE = true;
|
||||||
private static final boolean GV1_MIGRATION = true;
|
private static final boolean GV1_MIGRATION = true;
|
||||||
|
private static final boolean ANNOUNCEMENT_GROUPS = true;
|
||||||
|
private static final boolean SENDER_KEY = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param storageCapable Whether or not the user can use storage service. This is another way of
|
* @param storageCapable Whether or not the user can use storage service. This is another way of
|
||||||
* asking if the user has set a Signal PIN or not.
|
* 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, FeatureFlags.senderKey());
|
return new AccountAttributes.Capabilities(UUID_CAPABLE, GV2_CAPABLE, storageCapable, GV1_MIGRATION, SENDER_KEY, ANNOUNCEMENT_GROUPS, FeatureFlags.changeNumber());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,21 @@ import androidx.multidex.MultiDexApplication;
|
|||||||
import com.google.android.gms.security.ProviderInstaller;
|
import com.google.android.gms.security.ProviderInstaller;
|
||||||
|
|
||||||
import org.conscrypt.Conscrypt;
|
import org.conscrypt.Conscrypt;
|
||||||
|
import org.greenrobot.eventbus.EventBus;
|
||||||
import org.signal.aesgcmprovider.AesGcmProvider;
|
import org.signal.aesgcmprovider.AesGcmProvider;
|
||||||
import org.signal.core.util.concurrent.SignalExecutors;
|
import org.signal.core.util.concurrent.SignalExecutors;
|
||||||
import org.signal.core.util.logging.AndroidLogger;
|
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.logging.PersistentLogger;
|
|
||||||
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.mms.SignalGlideModule;
|
||||||
import org.signal.ringrtc.CallManager;
|
import org.signal.ringrtc.CallManager;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
|
||||||
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
|
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider;
|
||||||
|
import org.thoughtcrime.securesms.crypto.DatabaseSecretProvider;
|
||||||
|
import org.thoughtcrime.securesms.database.LogDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.SqlCipherLibraryLoader;
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
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;
|
||||||
@@ -48,18 +53,21 @@ 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.GroupV1MigrationJob;
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
import org.thoughtcrime.securesms.jobs.MultiDeviceContactUpdateJob;
|
||||||
|
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.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;
|
||||||
import org.thoughtcrime.securesms.logging.LogSecretProvider;
|
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.notifications.NotificationChannels;
|
import org.thoughtcrime.securesms.notifications.NotificationChannels;
|
||||||
import org.thoughtcrime.securesms.providers.BlobProvider;
|
import org.thoughtcrime.securesms.providers.BlobProvider;
|
||||||
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
|
|
||||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil;
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
import org.thoughtcrime.securesms.registration.RegistrationUtil;
|
||||||
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
import org.thoughtcrime.securesms.ringrtc.RingRtcLogger;
|
||||||
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
import org.thoughtcrime.securesms.service.DirectoryRefreshListener;
|
||||||
@@ -73,18 +81,26 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
|||||||
import org.thoughtcrime.securesms.util.AppStartup;
|
import org.thoughtcrime.securesms.util.AppStartup;
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
|
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
|
||||||
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
|
||||||
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.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.webrtc.voiceengine.WebRtcAudioManager;
|
|
||||||
import org.webrtc.voiceengine.WebRtcAudioUtils;
|
|
||||||
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
import org.whispersystems.libsignal.logging.SignalProtocolLoggerProvider;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.SocketException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
|
||||||
|
import io.reactivex.rxjava3.exceptions.UndeliverableException;
|
||||||
|
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
import rxdogtag2.RxDogTag;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Will be called once when the TextSecure process is created.
|
* Will be called once when the TextSecure process is created.
|
||||||
*
|
*
|
||||||
@@ -107,6 +123,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
Tracer.getInstance().start("Application#onCreate()");
|
Tracer.getInstance().start("Application#onCreate()");
|
||||||
AppStartup.getInstance().onApplicationCreate();
|
AppStartup.getInstance().onApplicationCreate();
|
||||||
|
SignalLocalMetrics.ColdStart.start();
|
||||||
|
|
||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
@@ -117,12 +134,19 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
super.onCreate();
|
super.onCreate();
|
||||||
|
|
||||||
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
AppStartup.getInstance().addBlocking("security-provider", this::initializeSecurityProvider)
|
||||||
|
.addBlocking("sqlcipher-init", () -> {
|
||||||
|
SqlCipherLibraryLoader.load();
|
||||||
|
SignalDatabase.init(this,
|
||||||
|
DatabaseSecretProvider.getOrCreateDatabaseSecret(this),
|
||||||
|
AttachmentSecretProvider.getInstance(this).getOrCreateAttachmentSecret());
|
||||||
|
})
|
||||||
.addBlocking("logging", () -> {
|
.addBlocking("logging", () -> {
|
||||||
initializeLogging();
|
initializeLogging();
|
||||||
Log.i(TAG, "onCreate()");
|
Log.i(TAG, "onCreate()");
|
||||||
})
|
})
|
||||||
.addBlocking("crash-handling", this::initializeCrashHandling)
|
.addBlocking("crash-handling", this::initializeCrashHandling)
|
||||||
.addBlocking("eat-db", () -> DatabaseFactory.getInstance(this))
|
.addBlocking("rx-init", this::initializeRx)
|
||||||
|
.addBlocking("event-bus", () -> EventBus.builder().logNoSubscriberMessages(false).installDefaultEventBus())
|
||||||
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
.addBlocking("app-dependencies", this::initializeAppDependencies)
|
||||||
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
|
.addBlocking("notification-channels", () -> NotificationChannels.create(this))
|
||||||
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
.addBlocking("first-launch", this::initializeFirstEverAppLaunch)
|
||||||
@@ -145,9 +169,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
})
|
})
|
||||||
.addBlocking("blob-provider", this::initializeBlobProvider)
|
.addBlocking("blob-provider", this::initializeBlobProvider)
|
||||||
.addBlocking("feature-flags", FeatureFlags::init)
|
.addBlocking("feature-flags", FeatureFlags::init)
|
||||||
|
.addBlocking("glide", () -> SignalGlideModule.setRegisterGlideComponents(new SignalGlideComponents()))
|
||||||
|
.addNonBlocking(this::cleanAvatarStorage)
|
||||||
.addNonBlocking(this::initializeRevealableMessageManager)
|
.addNonBlocking(this::initializeRevealableMessageManager)
|
||||||
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
.addNonBlocking(this::initializePendingRetryReceiptManager)
|
||||||
.addNonBlocking(this::initializeGcmCheck)
|
.addNonBlocking(this::initializeFcmCheck)
|
||||||
.addNonBlocking(this::initializeSignedPreKeyCheck)
|
.addNonBlocking(this::initializeSignedPreKeyCheck)
|
||||||
.addNonBlocking(this::initializePeriodicTasks)
|
.addNonBlocking(this::initializePeriodicTasks)
|
||||||
.addNonBlocking(this::initializeCircumvention)
|
.addNonBlocking(this::initializeCircumvention)
|
||||||
@@ -158,14 +184,18 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
.addNonBlocking(StorageSyncHelper::scheduleRoutineSync)
|
||||||
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
|
.addNonBlocking(() -> ApplicationDependencies.getJobManager().beginJobLoop())
|
||||||
.addNonBlocking(EmojiSource::refresh)
|
.addNonBlocking(EmojiSource::refresh)
|
||||||
|
.addNonBlocking(() -> ApplicationDependencies.getGiphyMp4Cache().onAppStart(this))
|
||||||
|
.addNonBlocking(this::ensureProfileUploaded)
|
||||||
.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)))
|
||||||
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
.addPostRender(() -> DownloadLatestEmojiDataJob.scheduleIfNecessary(this))
|
||||||
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
.addPostRender(EmojiSearchIndexDownloadJob::scheduleIfNecessary)
|
||||||
|
.addPostRender(() -> SignalDatabase.messageLog().trimOldMessages(System.currentTimeMillis(), FeatureFlags.retryRespondMaxAge()))
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
Log.d(TAG, "onCreate() took " + (System.currentTimeMillis() - startTime) + " ms");
|
||||||
|
SignalLocalMetrics.ColdStart.onApplicationCreateFinished();
|
||||||
Tracer.getInstance().end("Application#onCreate()");
|
Tracer.getInstance().end("Application#onCreate()");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,14 +204,15 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
long startTime = System.currentTimeMillis();
|
long startTime = System.currentTimeMillis();
|
||||||
Log.i(TAG, "App is now visible.");
|
Log.i(TAG, "App is now visible.");
|
||||||
|
|
||||||
ApplicationDependencies.getFrameRateTracker().begin();
|
ApplicationDependencies.getFrameRateTracker().start();
|
||||||
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
ApplicationDependencies.getMegaphoneRepository().onAppForegrounded();
|
||||||
|
ApplicationDependencies.getDeadlockDetector().start();
|
||||||
|
SubscriptionKeepAliveJob.launchSubscriberIdKeepAliveJobIfNecessary();
|
||||||
|
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
SignalExecutors.BOUNDED.execute(() -> {
|
||||||
FeatureFlags.refreshIfNecessary();
|
FeatureFlags.refreshIfNecessary();
|
||||||
ApplicationDependencies.getRecipientCache().warmUp();
|
ApplicationDependencies.getRecipientCache().warmUp();
|
||||||
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
|
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
|
||||||
GroupV1MigrationJob.enqueueRoutineMigrationsIfNecessary(this);
|
|
||||||
executePendingContactSync();
|
executePendingContactSync();
|
||||||
KeyCachingService.onAppForegrounded(this);
|
KeyCachingService.onAppForegrounded(this);
|
||||||
ApplicationDependencies.getShakeToReport().enable();
|
ApplicationDependencies.getShakeToReport().enable();
|
||||||
@@ -196,8 +227,9 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
Log.i(TAG, "App is no longer visible.");
|
Log.i(TAG, "App is no longer visible.");
|
||||||
KeyCachingService.onAppBackgrounded(this);
|
KeyCachingService.onAppBackgrounded(this);
|
||||||
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
ApplicationDependencies.getMessageNotifier().clearVisibleThread();
|
||||||
ApplicationDependencies.getFrameRateTracker().end();
|
ApplicationDependencies.getFrameRateTracker().stop();
|
||||||
ApplicationDependencies.getShakeToReport().disable();
|
ApplicationDependencies.getShakeToReport().disable();
|
||||||
|
ApplicationDependencies.getDeadlockDetector().stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
public PersistentLogger getPersistentLogger() {
|
public PersistentLogger getPersistentLogger() {
|
||||||
@@ -236,10 +268,15 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeLogging() {
|
private void initializeLogging() {
|
||||||
persistentLogger = new PersistentLogger(this, LogSecretProvider.getOrCreateAttachmentSecret(this), BuildConfig.VERSION_NAME);
|
persistentLogger = new PersistentLogger(this);
|
||||||
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
org.signal.core.util.logging.Log.initialize(FeatureFlags::internalUser, new AndroidLogger(), persistentLogger);
|
||||||
|
|
||||||
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
SignalProtocolLoggerProvider.setProvider(new CustomSignalProtocolLogger());
|
||||||
|
|
||||||
|
SignalExecutors.UNBOUNDED.execute(() -> {
|
||||||
|
Log.blockUntilAllWritesFinished();
|
||||||
|
LogDatabase.getInstance(this).trimToSize();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeCrashHandling() {
|
private void initializeCrashHandling() {
|
||||||
@@ -247,6 +284,30 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler));
|
Thread.setDefaultUncaughtExceptionHandler(new SignalUncaughtExceptionHandler(originalHandler));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void initializeRx() {
|
||||||
|
RxDogTag.install();
|
||||||
|
RxJavaPlugins.setInitIoSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED_IO, true, false));
|
||||||
|
RxJavaPlugins.setInitComputationSchedulerHandler(schedulerSupplier -> Schedulers.from(SignalExecutors.BOUNDED, true, false));
|
||||||
|
RxJavaPlugins.setErrorHandler(e -> {
|
||||||
|
boolean wasWrapped = false;
|
||||||
|
while ((e instanceof UndeliverableException || e instanceof AssertionError || e instanceof OnErrorNotImplementedException) && e.getCause() != null) {
|
||||||
|
wasWrapped = true;
|
||||||
|
e = e.getCause();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasWrapped && (e instanceof SocketException || e instanceof SocketTimeoutException || e instanceof InterruptedException)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.UncaughtExceptionHandler uncaughtExceptionHandler = Thread.currentThread().getUncaughtExceptionHandler();
|
||||||
|
if (uncaughtExceptionHandler == null) {
|
||||||
|
uncaughtExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
uncaughtExceptionHandler.uncaughtException(Thread.currentThread(), e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeApplicationMigrations() {
|
private void initializeApplicationMigrations() {
|
||||||
ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager());
|
ApplicationMigrations.onApplicationCreate(this, ApplicationDependencies.getJobManager());
|
||||||
}
|
}
|
||||||
@@ -261,7 +322,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
|
|
||||||
private void initializeFirstEverAppLaunch() {
|
private void initializeFirstEverAppLaunch() {
|
||||||
if (TextSecurePreferences.getFirstInstallVersion(this) == -1) {
|
if (TextSecurePreferences.getFirstInstallVersion(this) == -1) {
|
||||||
if (!SQLCipherOpenHelper.databaseFileExists(this) || VersionTracker.getDaysSinceFirstInstalled(this) < 365) {
|
if (!SignalDatabase.databaseFileExists(this) || VersionTracker.getDaysSinceFirstInstalled(this) < 365) {
|
||||||
Log.i(TAG, "First ever app launch!");
|
Log.i(TAG, "First ever app launch!");
|
||||||
AppInitialization.onFirstEverAppLaunch(this);
|
AppInitialization.onFirstEverAppLaunch(this);
|
||||||
}
|
}
|
||||||
@@ -277,11 +338,11 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeGcmCheck() {
|
private void initializeFcmCheck() {
|
||||||
if (TextSecurePreferences.isPushRegistered(this)) {
|
if (SignalStore.account().isRegistered()) {
|
||||||
long nextSetTime = TextSecurePreferences.getFcmTokenLastSetTime(this) + TimeUnit.HOURS.toMillis(6);
|
long nextSetTime = SignalStore.account().getFcmTokenLastSetTime() + TimeUnit.HOURS.toMillis(6);
|
||||||
|
|
||||||
if (TextSecurePreferences.getFcmToken(this) == null || nextSetTime <= System.currentTimeMillis()) {
|
if (SignalStore.account().getFcmToken() == null || nextSetTime <= System.currentTimeMillis()) {
|
||||||
ApplicationDependencies.getJobManager().add(new FcmRefreshJob());
|
ApplicationDependencies.getJobManager().add(new FcmRefreshJob());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,14 +380,6 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
|
|
||||||
private void initializeRingRtc() {
|
private void initializeRingRtc() {
|
||||||
try {
|
try {
|
||||||
if (RtcDeviceLists.hardwareAECBlocked()) {
|
|
||||||
WebRtcAudioUtils.setWebRtcBasedAcousticEchoCanceler(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!RtcDeviceLists.openSLESAllowed()) {
|
|
||||||
WebRtcAudioManager.setBlacklistDeviceForOpenSLESUsage(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
CallManager.initialize(this, new RingRtcLogger());
|
CallManager.initialize(this, new RingRtcLogger());
|
||||||
} catch (UnsatisfiedLinkError e) {
|
} catch (UnsatisfiedLinkError e) {
|
||||||
throw new AssertionError("Unable to load ringrtc library", e);
|
throw new AssertionError("Unable to load ringrtc library", e);
|
||||||
@@ -335,7 +388,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private void initializeCircumvention() {
|
private void initializeCircumvention() {
|
||||||
if (new SignalServiceNetworkAccess(ApplicationContext.this).isCensored(ApplicationContext.this)) {
|
if (ApplicationDependencies.getSignalServiceNetworkAccess().isCensored()) {
|
||||||
try {
|
try {
|
||||||
ProviderInstaller.installIfNeeded(ApplicationContext.this);
|
ProviderInstaller.installIfNeeded(ApplicationContext.this);
|
||||||
} catch (Throwable t) {
|
} catch (Throwable t) {
|
||||||
@@ -344,6 +397,13 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ensureProfileUploaded() {
|
||||||
|
if (SignalStore.account().isRegistered() && !SignalStore.registrationValues().hasUploadedProfile() && !Recipient.self().getProfileName().isEmpty()) {
|
||||||
|
Log.w(TAG, "User has a profile, but has not uploaded one. Uploading now.");
|
||||||
|
ApplicationDependencies.getJobManager().add(new ProfileUploadJob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void executePendingContactSync() {
|
private void executePendingContactSync() {
|
||||||
if (TextSecurePreferences.needsFullContactSync(this)) {
|
if (TextSecurePreferences.needsFullContactSync(this)) {
|
||||||
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true));
|
ApplicationDependencies.getJobManager().add(new MultiDeviceContactUpdateJob(true));
|
||||||
@@ -367,9 +427,14 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
|
|||||||
BlobProvider.getInstance().initialize(this);
|
BlobProvider.getInstance().initialize(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
private void cleanAvatarStorage() {
|
||||||
|
AvatarPickerStorage.cleanOrphans(this);
|
||||||
|
}
|
||||||
|
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
private void initializeCleanup() {
|
private void initializeCleanup() {
|
||||||
int deleted = DatabaseFactory.getAttachmentDatabase(this).deleteAbandonedPreuploadedAttachments();
|
int deleted = SignalDatabase.attachments().deleteAbandonedPreuploadedAttachments();
|
||||||
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
|
Log.i(TAG, "Deleted " + deleted + " abandoned attachments.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.bumptech.glide.request.target.Target;
|
|||||||
import com.bumptech.glide.request.transition.Transition;
|
import com.bumptech.glide.request.transition.Transition;
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
|
||||||
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
|
||||||
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
|
||||||
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto;
|
||||||
@@ -71,12 +72,14 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
|||||||
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
getWindow().setSharedElementReturnTransition(inflater.inflateTransition(R.transition.full_screen_avatar_image_return_transition_set));
|
||||||
}
|
}
|
||||||
|
|
||||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||||
ImageView avatar = findViewById(R.id.avatar);
|
EmojiTextView title = findViewById(R.id.title);
|
||||||
|
ImageView avatar = findViewById(R.id.avatar);
|
||||||
|
|
||||||
setSupportActionBar(toolbar);
|
setSupportActionBar(toolbar);
|
||||||
|
|
||||||
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
requireSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||||
|
requireSupportActionBar().setDisplayShowTitleEnabled(false);
|
||||||
|
|
||||||
Context context = getApplicationContext();
|
Context context = getApplicationContext();
|
||||||
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
|
RecipientId recipientId = RecipientId.from(getIntent().getStringExtra(RECIPIENT_ID_EXTRA));
|
||||||
@@ -122,7 +125,7 @@ public final class AvatarPreviewActivity extends PassphraseRequiredActivity {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
toolbar.setTitle(recipient.getDisplayName(context));
|
title.setText(recipient.getDisplayName(context));
|
||||||
});
|
});
|
||||||
|
|
||||||
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
|
FullscreenHelper fullscreenHelper = new FullscreenHelper(this);
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ public abstract class BaseActivity extends AppCompatActivity {
|
|||||||
int appCompatNightMode = getDelegate().getLocalNightMode() != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED ? getDelegate().getLocalNightMode()
|
int appCompatNightMode = getDelegate().getLocalNightMode() != AppCompatDelegate.MODE_NIGHT_UNSPECIFIED ? getDelegate().getLocalNightMode()
|
||||||
: AppCompatDelegate.getDefaultNightMode();
|
: AppCompatDelegate.getDefaultNightMode();
|
||||||
|
|
||||||
configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode);
|
configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK) | mapNightModeToConfigurationUiMode(newBase, appCompatNightMode);
|
||||||
|
configuration.orientation = Configuration.ORIENTATION_UNDEFINED;
|
||||||
|
|
||||||
applyOverrideConfiguration(configuration);
|
applyOverrideConfiguration(configuration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import org.thoughtcrime.securesms.contactshare.Contact;
|
|||||||
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
import org.thoughtcrime.securesms.conversation.ConversationMessage;
|
||||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||||
|
import org.thoughtcrime.securesms.conversation.mutiselect.Multiselectable;
|
||||||
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
|
||||||
@@ -24,31 +26,29 @@ 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.thoughtcrime.securesms.video.exo.AttachmentMediaSourceFactory;
|
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
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.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable {
|
public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, Colorizable, Multiselectable {
|
||||||
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
void bind(@NonNull LifecycleOwner lifecycleOwner,
|
||||||
@NonNull ConversationMessage messageRecord,
|
@NonNull ConversationMessage messageRecord,
|
||||||
@NonNull Optional<MessageRecord> previousMessageRecord,
|
@NonNull Optional<MessageRecord> previousMessageRecord,
|
||||||
@NonNull Optional<MessageRecord> nextMessageRecord,
|
@NonNull Optional<MessageRecord> nextMessageRecord,
|
||||||
@NonNull GlideRequests glideRequests,
|
@NonNull GlideRequests glideRequests,
|
||||||
@NonNull Locale locale,
|
@NonNull Locale locale,
|
||||||
@NonNull Set<ConversationMessage> batchSelected,
|
@NonNull Set<MultiselectPart> batchSelected,
|
||||||
@NonNull Recipient recipients,
|
@NonNull Recipient recipients,
|
||||||
@Nullable String searchQuery,
|
@Nullable String searchQuery,
|
||||||
boolean pulseMention,
|
boolean pulseMention,
|
||||||
boolean hasWallpaper,
|
boolean hasWallpaper,
|
||||||
boolean isMessageRequestAccepted,
|
boolean isMessageRequestAccepted,
|
||||||
@NonNull AttachmentMediaSourceFactory attachmentMediaSourceFactory,
|
|
||||||
boolean canPlayInline,
|
boolean canPlayInline,
|
||||||
@NonNull Colorizer colorizer);
|
@NonNull Colorizer colorizer);
|
||||||
|
|
||||||
ConversationMessage getConversationMessage();
|
@NonNull ConversationMessage getConversationMessage();
|
||||||
|
|
||||||
void setEventListener(@Nullable EventListener listener);
|
void setEventListener(@Nullable EventListener listener);
|
||||||
|
|
||||||
@@ -56,6 +56,10 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
// Intentionally Blank.
|
// Intentionally Blank.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
default void updateContactNameColor() {
|
||||||
|
// Intentionally Blank.
|
||||||
|
}
|
||||||
|
|
||||||
interface EventListener {
|
interface EventListener {
|
||||||
void onQuoteClicked(MmsMessageRecord messageRecord);
|
void onQuoteClicked(MmsMessageRecord messageRecord);
|
||||||
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
void onLinkPreviewClicked(@NonNull LinkPreview linkPreview);
|
||||||
@@ -66,15 +70,17 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
void onAddToContactsClicked(@NonNull Contact contact);
|
void onAddToContactsClicked(@NonNull Contact contact);
|
||||||
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
|
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
|
||||||
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
|
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
|
||||||
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
|
void onReactionClicked(@NonNull MultiselectPart multiselectPart, long messageId, boolean isMms);
|
||||||
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
void onGroupMemberClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
|
||||||
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
|
||||||
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
|
void onMessageWithRecaptchaNeededClicked(@NonNull MessageRecord messageRecord);
|
||||||
|
void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId);
|
||||||
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
void onRegisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||||
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
void onUnregisterVoiceNoteCallbacks(@NonNull Observer<VoiceNotePlaybackState> onPlaybackStartObserver);
|
||||||
void onVoiceNotePause(@NonNull Uri uri);
|
void onVoiceNotePause(@NonNull Uri uri);
|
||||||
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
|
void onVoiceNotePlay(@NonNull Uri uri, long messageId, double position);
|
||||||
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
|
void onVoiceNoteSeekTo(@NonNull Uri uri, double position);
|
||||||
|
void onVoiceNotePlaybackSpeedChanged(@NonNull Uri uri, float speed);
|
||||||
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
|
void onGroupMigrationLearnMoreClicked(@NonNull GroupMigrationMembershipChange membershipChange);
|
||||||
void onChatSessionRefreshLearnMoreClicked();
|
void onChatSessionRefreshLearnMoreClicked();
|
||||||
void onBadDecryptLearnMoreClicked(@NonNull RecipientId author);
|
void onBadDecryptLearnMoreClicked(@NonNull RecipientId author);
|
||||||
@@ -85,6 +91,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
|||||||
void onPlayInlineContent(ConversationMessage conversationMessage);
|
void onPlayInlineContent(ConversationMessage conversationMessage);
|
||||||
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
|
void onInMemoryMessageClicked(@NonNull InMemoryMessageRecord messageRecord);
|
||||||
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
|
void onViewGroupDescriptionChange(@Nullable GroupId groupId, @NonNull String description, boolean isMessageRequestAccepted);
|
||||||
|
void onChangeNumberUpdateContact(@NonNull Recipient recipient);
|
||||||
|
|
||||||
/** @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);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.thoughtcrime.securesms;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.conversationlist.model.Conversation;
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -13,8 +15,8 @@ public interface BindableConversationListItem extends Unbindable {
|
|||||||
void bind(@NonNull ThreadRecord thread,
|
void bind(@NonNull ThreadRecord thread,
|
||||||
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
@NonNull GlideRequests glideRequests, @NonNull Locale locale,
|
||||||
@NonNull Set<Long> typingThreads,
|
@NonNull Set<Long> typingThreads,
|
||||||
@NonNull Set<Long> selectedThreads, boolean batchMode);
|
@NonNull ConversationSet selectedConversations);
|
||||||
|
|
||||||
void setBatchMode(boolean batchMode);
|
void setSelectedConversations(@NonNull ConversationSet conversations);
|
||||||
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
|
void updateTypingIndicator(@NonNull Set<Long> typingThreads);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import androidx.lifecycle.Lifecycle;
|
|||||||
|
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
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.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ public final class BlockUnblockDialog {
|
|||||||
Resources resources = context.getResources();
|
Resources resources = context.getResources();
|
||||||
|
|
||||||
if (recipient.isGroup()) {
|
if (recipient.isGroup()) {
|
||||||
if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) {
|
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
|
||||||
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context)));
|
builder.setTitle(resources.getString(R.string.BlockUnblockDialog_block_and_leave_s, recipient.getDisplayName(context)));
|
||||||
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
|
builder.setMessage(R.string.BlockUnblockDialog_you_will_no_longer_receive_messages_or_updates);
|
||||||
builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run()));
|
builder.setPositiveButton(R.string.BlockUnblockDialog_block_and_leave, ((dialog, which) -> onBlock.run()));
|
||||||
@@ -104,7 +104,7 @@ public final class BlockUnblockDialog {
|
|||||||
Resources resources = context.getResources();
|
Resources resources = context.getResources();
|
||||||
|
|
||||||
if (recipient.isGroup()) {
|
if (recipient.isGroup()) {
|
||||||
if (DatabaseFactory.getGroupDatabase(context).isActive(recipient.requireGroupId())) {
|
if (SignalDatabase.groups().isActive(recipient.requireGroupId())) {
|
||||||
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_group_members_will_be_able_to_add_you);
|
builder.setMessage(R.string.BlockUnblockDialog_group_members_will_be_able_to_add_you);
|
||||||
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
builder.setPositiveButton(R.string.RecipientPreferenceActivity_unblock, ((dialog, which) -> onUnblock.run()));
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.os.AsyncTask;
|
|
||||||
import android.text.SpannableString;
|
|
||||||
import android.text.Spanned;
|
|
||||||
import android.text.method.LinkMovementMethod;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
|
||||||
import org.thoughtcrime.securesms.crypto.storage.TextSecureIdentityKeyStore;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
|
||||||
import org.thoughtcrime.securesms.database.MessageDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.PushDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
|
||||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
|
||||||
import org.thoughtcrime.securesms.jobs.PushDecryptMessageJob;
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
|
||||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
|
||||||
import org.thoughtcrime.securesms.util.Base64;
|
|
||||||
import org.thoughtcrime.securesms.util.VerifySpan;
|
|
||||||
import org.whispersystems.libsignal.SignalProtocolAddress;
|
|
||||||
import org.whispersystems.libsignal.util.guava.Optional;
|
|
||||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
|
||||||
import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope;
|
|
||||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class ConfirmIdentityDialog extends AlertDialog {
|
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
|
||||||
private static final String TAG = Log.tag(ConfirmIdentityDialog.class);
|
|
||||||
|
|
||||||
private OnClickListener callback;
|
|
||||||
|
|
||||||
public ConfirmIdentityDialog(Context context,
|
|
||||||
MessageRecord messageRecord,
|
|
||||||
IdentityKeyMismatch mismatch)
|
|
||||||
{
|
|
||||||
super(context);
|
|
||||||
|
|
||||||
Recipient recipient = Recipient.resolved(mismatch.getRecipientId(context));
|
|
||||||
String name = recipient.getDisplayName(context);
|
|
||||||
String introduction = context.getString(R.string.ConfirmIdentityDialog_your_safety_number_with_s_has_changed, name, name);
|
|
||||||
SpannableString spannableString = new SpannableString(introduction + " " +
|
|
||||||
context.getString(R.string.ConfirmIdentityDialog_you_may_wish_to_verify_your_safety_number_with_this_contact));
|
|
||||||
|
|
||||||
spannableString.setSpan(new VerifySpan(context, mismatch),
|
|
||||||
introduction.length()+1, spannableString.length(),
|
|
||||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
|
||||||
|
|
||||||
setTitle(name);
|
|
||||||
setMessage(spannableString);
|
|
||||||
|
|
||||||
setButton(AlertDialog.BUTTON_POSITIVE, context.getString(R.string.ConfirmIdentityDialog_accept), new AcceptListener(messageRecord, mismatch, recipient.getId()));
|
|
||||||
setButton(AlertDialog.BUTTON_NEGATIVE, context.getString(android.R.string.cancel), new CancelListener());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void show() {
|
|
||||||
super.show();
|
|
||||||
((TextView)this.findViewById(android.R.id.message))
|
|
||||||
.setMovementMethod(LinkMovementMethod.getInstance());
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setCallback(OnClickListener callback) {
|
|
||||||
this.callback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class AcceptListener implements OnClickListener {
|
|
||||||
|
|
||||||
private final MessageRecord messageRecord;
|
|
||||||
private final IdentityKeyMismatch mismatch;
|
|
||||||
private final RecipientId recipientId;
|
|
||||||
|
|
||||||
private AcceptListener(MessageRecord messageRecord, IdentityKeyMismatch mismatch, RecipientId recipientId) {
|
|
||||||
this.messageRecord = messageRecord;
|
|
||||||
this.mismatch = mismatch;
|
|
||||||
this.recipientId = recipientId;
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int which) {
|
|
||||||
new AsyncTask<Void, Void, Void>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected Void doInBackground(Void... params) {
|
|
||||||
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
|
||||||
SignalProtocolAddress mismatchAddress = new SignalProtocolAddress(Recipient.resolved(recipientId).requireServiceId(), 1);
|
|
||||||
TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(getContext());
|
|
||||||
|
|
||||||
identityKeyStore.saveIdentity(mismatchAddress, mismatch.getIdentityKey(), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
processMessageRecord(messageRecord);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processMessageRecord(MessageRecord messageRecord) {
|
|
||||||
if (messageRecord.isOutgoing()) processOutgoingMessageRecord(messageRecord);
|
|
||||||
else processIncomingMessageRecord(messageRecord);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processOutgoingMessageRecord(MessageRecord messageRecord) {
|
|
||||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
|
||||||
MessageDatabase mmsDatabase = DatabaseFactory.getMmsDatabase(getContext());
|
|
||||||
|
|
||||||
if (messageRecord.isMms()) {
|
|
||||||
mmsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
|
||||||
mismatch.getRecipientId(getContext()),
|
|
||||||
mismatch.getIdentityKey());
|
|
||||||
|
|
||||||
if (messageRecord.getRecipient().isPushGroup()) {
|
|
||||||
MessageSender.resendGroupMessage(getContext(), messageRecord, Recipient.resolved(mismatch.getRecipientId(getContext())).getId());
|
|
||||||
} else {
|
|
||||||
MessageSender.resend(getContext(), messageRecord);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
|
||||||
mismatch.getRecipientId(getContext()),
|
|
||||||
mismatch.getIdentityKey());
|
|
||||||
|
|
||||||
MessageSender.resend(getContext(), messageRecord);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void processIncomingMessageRecord(MessageRecord messageRecord) {
|
|
||||||
try {
|
|
||||||
MessageDatabase smsDatabase = DatabaseFactory.getSmsDatabase(getContext());
|
|
||||||
|
|
||||||
smsDatabase.removeMismatchedIdentity(messageRecord.getId(),
|
|
||||||
mismatch.getRecipientId(getContext()),
|
|
||||||
mismatch.getIdentityKey());
|
|
||||||
|
|
||||||
boolean legacy = !messageRecord.isContentBundleKeyExchange();
|
|
||||||
|
|
||||||
SignalServiceEnvelope envelope = new SignalServiceEnvelope(SignalServiceProtos.Envelope.Type.PREKEY_BUNDLE_VALUE,
|
|
||||||
Optional.of(RecipientUtil.toSignalServiceAddress(getContext(), messageRecord.getIndividualRecipient())),
|
|
||||||
messageRecord.getRecipientDeviceId(),
|
|
||||||
messageRecord.getDateSent(),
|
|
||||||
legacy ? Base64.decode(messageRecord.getBody()) : null,
|
|
||||||
!legacy ? Base64.decode(messageRecord.getBody()) : null,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
null);
|
|
||||||
|
|
||||||
ApplicationDependencies.getJobManager().add(new PushDecryptMessageJob(getContext(), envelope, messageRecord.getId()));
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new AssertionError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
|
||||||
|
|
||||||
if (callback != null) callback.onClick(null, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class CancelListener implements OnClickListener {
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int which) {
|
|
||||||
if (callback != null) callback.onClick(null, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -20,13 +20,13 @@ import android.content.Context;
|
|||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
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.ContactFilterToolbar;
|
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.DirectoryHelper;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
|
||||||
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;
|
||||||
@@ -36,6 +36,7 @@ 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.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base activity container for selecting a list of contacts.
|
* Base activity container for selecting a list of contacts.
|
||||||
@@ -56,7 +57,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
|
|
||||||
protected ContactSelectionListFragment contactsFragment;
|
protected ContactSelectionListFragment contactsFragment;
|
||||||
|
|
||||||
private ContactFilterToolbar toolbar;
|
private Toolbar toolbar;
|
||||||
|
private ContactFilterView contactFilterView;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPreCreate() {
|
protected void onPreCreate() {
|
||||||
@@ -73,6 +75,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
|
|
||||||
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
setContentView(getIntent().getIntExtra(EXTRA_LAYOUT_RES_ID, R.layout.contact_selection_activity));
|
||||||
|
|
||||||
|
initializeContactFilterView();
|
||||||
initializeToolbar();
|
initializeToolbar();
|
||||||
initializeResources();
|
initializeResources();
|
||||||
initializeSearch();
|
initializeSearch();
|
||||||
@@ -84,16 +87,23 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
dynamicTheme.onResume(this);
|
dynamicTheme.onResume(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ContactFilterToolbar getToolbar() {
|
protected Toolbar getToolbar() {
|
||||||
return toolbar;
|
return toolbar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected ContactFilterView getContactFilterView() {
|
||||||
|
return contactFilterView;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initializeContactFilterView() {
|
||||||
|
this.contactFilterView = findViewById(R.id.contact_filter_edit_text);
|
||||||
|
}
|
||||||
|
|
||||||
private void initializeToolbar() {
|
private void initializeToolbar() {
|
||||||
this.toolbar = findViewById(R.id.toolbar);
|
this.toolbar = findViewById(R.id.toolbar);
|
||||||
setSupportActionBar(toolbar);
|
setSupportActionBar(toolbar);
|
||||||
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
getSupportActionBar().setDisplayHomeAsUpEnabled(false);
|
||||||
getSupportActionBar().setDisplayShowTitleEnabled(false);
|
|
||||||
getSupportActionBar().setIcon(null);
|
getSupportActionBar().setIcon(null);
|
||||||
getSupportActionBar().setLogo(null);
|
getSupportActionBar().setLogo(null);
|
||||||
}
|
}
|
||||||
@@ -104,7 +114,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeSearch() {
|
private void initializeSearch() {
|
||||||
toolbar.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
contactFilterView.setOnFilterChangedListener(filter -> contactsFragment.setQueryFilter(filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -113,8 +123,8 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||||
return true;
|
callback.accept(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -155,7 +165,7 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
|
|||||||
ContactSelectionActivity activity = this.activity.get();
|
ContactSelectionActivity activity = this.activity.get();
|
||||||
|
|
||||||
if (activity != null && !activity.isFinishing()) {
|
if (activity != null && !activity.isFinishing()) {
|
||||||
activity.toolbar.clear();
|
activity.contactFilterView.clear();
|
||||||
activity.contactsFragment.resetQueryFilter();
|
activity.contactsFragment.resetQueryFilter();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import android.widget.Toast;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.annotation.Px;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
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;
|
||||||
@@ -53,17 +54,19 @@ 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.chip.ChipGroup;
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
import com.pnikosis.materialishprogress.ProgressWheel;
|
import com.pnikosis.materialishprogress.ProgressWheel;
|
||||||
|
|
||||||
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.emoji.WarningTextView;
|
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.ContactChip;
|
||||||
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.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.sync.DirectoryHelper;
|
||||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||||
@@ -74,7 +77,6 @@ import org.thoughtcrime.securesms.permissions.Permissions;
|
|||||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
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.util.StickyHeaderDecoration;
|
|
||||||
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;
|
||||||
@@ -88,6 +90,7 @@ import java.io.IOException;
|
|||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fragment for selecting a one or more contacts from a list.
|
* Fragment for selecting a one or more contacts from a list.
|
||||||
@@ -131,9 +134,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
private ContactSelectionListAdapter cursorRecyclerViewAdapter;
|
||||||
private ChipGroup chipGroup;
|
private ChipGroup chipGroup;
|
||||||
private HorizontalScrollView chipGroupScrollContainer;
|
private HorizontalScrollView chipGroupScrollContainer;
|
||||||
private WarningTextView groupLimit;
|
|
||||||
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
private OnSelectionLimitReachedListener onSelectionLimitReachedListener;
|
||||||
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
private AbstractContactsCursorLoaderFactoryProvider cursorFactoryProvider;
|
||||||
|
private View shadowView;
|
||||||
|
private ToolbarShadowAnimationHelper toolbarShadowAnimationHelper;
|
||||||
|
|
||||||
|
|
||||||
@Nullable private FixedViewsAdapter headerAdapter;
|
@Nullable private FixedViewsAdapter headerAdapter;
|
||||||
@@ -175,12 +179,16 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
|
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (getParentFragment() instanceof OnSelectionLimitReachedListener) {
|
||||||
|
onSelectionLimitReachedListener = (OnSelectionLimitReachedListener) getParentFragment();
|
||||||
|
}
|
||||||
|
|
||||||
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
if (context instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
if (getParentFragment() instanceof AbstractContactsCursorLoaderFactoryProvider) {
|
||||||
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) context;
|
cursorFactoryProvider = (AbstractContactsCursorLoaderFactoryProvider) getParentFragment();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,9 +241,12 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
showContactsProgress = view.findViewById(R.id.progress);
|
showContactsProgress = view.findViewById(R.id.progress);
|
||||||
chipGroup = view.findViewById(R.id.chipGroup);
|
chipGroup = view.findViewById(R.id.chipGroup);
|
||||||
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
chipGroupScrollContainer = view.findViewById(R.id.chipGroupScrollContainer);
|
||||||
groupLimit = view.findViewById(R.id.group_limit);
|
|
||||||
constraintLayout = view.findViewById(R.id.container);
|
constraintLayout = view.findViewById(R.id.container);
|
||||||
|
shadowView = view.findViewById(R.id.toolbar_shadow);
|
||||||
|
|
||||||
|
toolbarShadowAnimationHelper = new ToolbarShadowAnimationHelper(shadowView);
|
||||||
|
|
||||||
|
recyclerView.addOnScrollListener(toolbarShadowAnimationHelper);
|
||||||
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
recyclerView.setLayoutManager(new LinearLayoutManager(getActivity()));
|
||||||
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
recyclerView.setItemAnimator(new DefaultItemAnimator() {
|
||||||
@Override
|
@Override
|
||||||
@@ -256,7 +267,9 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
recyclerView.setClipToPadding(recyclerViewClipping);
|
recyclerView.setClipToPadding(recyclerViewClipping);
|
||||||
|
|
||||||
swipeRefresh.setEnabled(arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true)));
|
boolean isRefreshable = arguments.getBoolean(REFRESHABLE, intent.getBooleanExtra(REFRESHABLE, true));
|
||||||
|
swipeRefresh.setNestedScrollingEnabled(isRefreshable);
|
||||||
|
swipeRefresh.setEnabled(isRefreshable);
|
||||||
|
|
||||||
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
hideCount = arguments.getBoolean(HIDE_COUNT, intent.getBooleanExtra(HIDE_COUNT, false));
|
||||||
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
selectionLimit = arguments.getParcelable(SELECTION_LIMITS);
|
||||||
@@ -272,8 +285,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
currentSelection = getCurrentSelection();
|
currentSelection = getCurrentSelection();
|
||||||
|
|
||||||
updateGroupLimit(getChipCount());
|
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +292,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
return getArguments() != null ? getArguments() : new Bundle();
|
return getArguments() != null ? getArguments() : new Bundle();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateGroupLimit(int chipCount) {
|
|
||||||
int members = currentSelection.size() + chipCount;
|
|
||||||
groupLimit.setText(getResources().getQuantityString(R.plurals.ContactSelectionListFragment_d_members, members, members));
|
|
||||||
groupLimit.setVisibility(isMulti && !hideCount ? View.VISIBLE : View.GONE);
|
|
||||||
groupLimit.setWarning(selectionWarningLimitExceeded());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||||
@@ -309,6 +313,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
return cursorRecyclerViewAdapter.getSelectedContactsCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getTotalMemberCount() {
|
||||||
|
if (cursorRecyclerViewAdapter == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cursorRecyclerViewAdapter.getSelectedContactsCount() + cursorRecyclerViewAdapter.getCurrentContactsCount();
|
||||||
|
}
|
||||||
|
|
||||||
private Set<RecipientId> getCurrentSelection() {
|
private Set<RecipientId> getCurrentSelection() {
|
||||||
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
List<RecipientId> currentSelection = safeArguments().getParcelableArrayList(CURRENT_SELECTION);
|
||||||
if (currentSelection == null) {
|
if (currentSelection == null) {
|
||||||
@@ -349,8 +361,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
concatenateAdapter.addAdapter(footerAdapter);
|
concatenateAdapter.addAdapter(footerAdapter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recyclerView.addItemDecoration(new LetterHeaderDecoration(requireContext(), this::hideLetterHeaders));
|
||||||
recyclerView.setAdapter(concatenateAdapter);
|
recyclerView.setAdapter(concatenateAdapter);
|
||||||
recyclerView.addItemDecoration(new StickyHeaderDecoration(concatenateAdapter, true, true, 0));
|
|
||||||
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
|
||||||
@@ -361,6 +373,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (onContactSelectedListener != null) {
|
||||||
|
onContactSelectedListener.onSelectionChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hideLetterHeaders() {
|
||||||
|
return hasQueryFilter() || shouldDisplayRecents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
private View createInviteActionView(@NonNull ListCallback listCallback) {
|
||||||
@@ -425,11 +445,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setRecyclerViewPaddingBottom(@Px int paddingBottom) {
|
||||||
|
ViewUtil.setPaddingBottom(recyclerView, paddingBottom);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
public @NonNull Loader<Cursor> onCreateLoader(int id, Bundle args) {
|
||||||
FragmentActivity activity = requireActivity();
|
FragmentActivity activity = requireActivity();
|
||||||
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
int displayMode = safeArguments().getInt(DISPLAY_MODE, activity.getIntent().getIntExtra(DISPLAY_MODE, DisplayMode.FLAG_ALL));
|
||||||
boolean displayRecents = safeArguments().getBoolean(RECENTS, activity.getIntent().getBooleanExtra(RECENTS, false));
|
boolean displayRecents = shouldDisplayRecents();
|
||||||
|
|
||||||
if (cursorFactoryProvider != null) {
|
if (cursorFactoryProvider != null) {
|
||||||
return cursorFactoryProvider.get().create();
|
return cursorFactoryProvider.get().create();
|
||||||
@@ -475,6 +499,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
fastScroller.setVisibility(View.GONE);
|
fastScroller.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean shouldDisplayRecents() {
|
||||||
|
return safeArguments().getBoolean(RECENTS, requireActivity().getIntent().getBooleanExtra(RECENTS, false));
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
@SuppressLint("StaticFieldLeak")
|
||||||
private void handleContactPermissionGranted() {
|
private void handleContactPermissionGranted() {
|
||||||
final Context context = requireContext();
|
final Context context = requireContext();
|
||||||
@@ -543,7 +571,7 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
AlertDialog loadingDialog = SimpleProgressDialog.show(requireContext());
|
||||||
|
|
||||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||||
return UsernameUtil.fetchUuidForUsername(requireContext(), contact.getNumber());
|
return UsernameUtil.fetchAciForUsername(requireContext(), contact.getNumber());
|
||||||
}, uuid -> {
|
}, uuid -> {
|
||||||
loadingDialog.dismiss();
|
loadingDialog.dismiss();
|
||||||
if (uuid.isPresent()) {
|
if (uuid.isPresent()) {
|
||||||
@@ -551,28 +579,32 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
|
||||||
|
|
||||||
if (onContactSelectedListener != null) {
|
if (onContactSelectedListener != null) {
|
||||||
if (onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null)) {
|
onContactSelectedListener.onBeforeContactSelected(Optional.of(recipient.getId()), null, allowed -> {
|
||||||
markContactSelected(selected);
|
if (allowed) {
|
||||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
markContactSelected(selected);
|
||||||
}
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
markContactSelected(selected);
|
markContactSelected(selected);
|
||||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
new AlertDialog.Builder(requireContext())
|
new MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
|
.setTitle(R.string.ContactSelectionListFragment_username_not_found)
|
||||||
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
|
.setMessage(getString(R.string.ContactSelectionListFragment_s_is_not_a_signal_user, contact.getNumber()))
|
||||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (onContactSelectedListener != null) {
|
if (onContactSelectedListener != null) {
|
||||||
if (onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber())) {
|
onContactSelectedListener.onBeforeContactSelected(contact.getRecipientId(), contact.getNumber(), allowed -> {
|
||||||
markContactSelected(selectedContact);
|
if (allowed) {
|
||||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
markContactSelected(selectedContact);
|
||||||
}
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
markContactSelected(selectedContact);
|
markContactSelected(selectedContact);
|
||||||
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
cursorRecyclerViewAdapter.notifyItemRangeChanged(0, cursorRecyclerViewAdapter.getItemCount(), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
|
||||||
@@ -606,12 +638,19 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
if (isMulti) {
|
if (isMulti) {
|
||||||
addChipForSelectedContact(selectedContact);
|
addChipForSelectedContact(selectedContact);
|
||||||
}
|
}
|
||||||
|
if (onContactSelectedListener != null) {
|
||||||
|
onContactSelectedListener.onSelectionChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
removeChipForContact(selectedContact);
|
||||||
|
|
||||||
|
if (onContactSelectedListener != null) {
|
||||||
|
onContactSelectedListener.onSelectionChanged();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removeChipForContact(@NonNull SelectedContact contact) {
|
private void removeChipForContact(@NonNull SelectedContact contact) {
|
||||||
@@ -622,8 +661,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGroupLimit(getChipCount());
|
|
||||||
|
|
||||||
if (getChipCount() == 0) {
|
if (getChipCount() == 0) {
|
||||||
setChipGroupVisibility(ConstraintSet.GONE);
|
setChipGroupVisibility(ConstraintSet.GONE);
|
||||||
}
|
}
|
||||||
@@ -660,6 +697,11 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void endTransition(LayoutTransition transition, ViewGroup container, View view, int transitionType) {
|
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) {
|
if (view == chip && transitionType == LayoutTransition.APPEARING) {
|
||||||
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
chipGroup.getLayoutTransition().removeTransitionListener(this);
|
||||||
registerChipRecipientObserver(chip, recipient.live());
|
registerChipRecipientObserver(chip, recipient.live());
|
||||||
@@ -673,7 +715,6 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
|
|
||||||
private void addChip(@NonNull ContactChip chip) {
|
private void addChip(@NonNull ContactChip chip) {
|
||||||
chipGroup.addView(chip);
|
chipGroup.addView(chip);
|
||||||
updateGroupLimit(getChipCount());
|
|
||||||
if (selectionWarningLimitReachedExactly()) {
|
if (selectionWarningLimitReachedExactly()) {
|
||||||
if (onSelectionLimitReachedListener != null) {
|
if (onSelectionLimitReachedListener != null) {
|
||||||
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
onSelectionLimitReachedListener.onSuggestedLimitReached(selectionLimit.getRecommendedLimit());
|
||||||
@@ -723,9 +764,10 @@ public final class ContactSelectionListFragment extends LoggingFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
public interface OnContactSelectedListener {
|
public interface OnContactSelectedListener {
|
||||||
/** @return True if the contact is allowed to be selected, otherwise false. */
|
/** Provides an opportunity to disallow selecting an item. Call the callback with false to disallow, or true to allow it. */
|
||||||
boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number);
|
void onBeforeContactSelected(Optional<RecipientId> recipientId, @Nullable String number, Consumer<Boolean> callback);
|
||||||
void onContactDeselected(Optional<RecipientId> recipientId, String number);
|
void onContactDeselected(Optional<RecipientId> recipientId, @Nullable String number);
|
||||||
|
void onSelectionChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface OnSelectionLimitReachedListener {
|
public interface OnSelectionLimitReachedListener {
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onQrDataFound(final String data) {
|
public void onQrDataFound(@NonNull final String data) {
|
||||||
ThreadUtil.runOnMain(() -> {
|
ThreadUtil.runOnMain(() -> {
|
||||||
((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
|
((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
|
||||||
Uri uri = Uri.parse(data);
|
Uri uri = Uri.parse(data);
|
||||||
@@ -150,6 +150,7 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||||
@@ -190,7 +191,6 @@ public class DeviceActivity extends PassphraseRequiredActivity
|
|||||||
Optional<byte[]> profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext()));
|
Optional<byte[]> profileKey = Optional.of(ProfileKeyUtil.getProfileKey(getContext()));
|
||||||
|
|
||||||
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
|
TextSecurePreferences.setMultiDevice(DeviceActivity.this, true);
|
||||||
TextSecurePreferences.setIsUnidentifiedDeliveryEnabled(context, false);
|
|
||||||
accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode);
|
accountManager.addDevice(ephemeralId, publicKey, identityKeyPair, profileKey, verificationCode);
|
||||||
|
|
||||||
return SUCCESS;
|
return SUCCESS;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public final class GroupMembersDialog {
|
|||||||
.show();
|
.show();
|
||||||
|
|
||||||
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
GroupMemberListView memberListView = dialog.findViewById(R.id.list_members);
|
||||||
|
memberListView.initializeAdapter(fragmentActivity);
|
||||||
|
|
||||||
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
|
LiveGroup liveGroup = new LiveGroup(groupRecipient.requireGroupId());
|
||||||
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
|
LiveData<List<GroupMemberEntry.FullMember>> fullMembers = liveGroup.getFullMembers();
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ package org.thoughtcrime.securesms;
|
|||||||
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.graphics.PorterDuff;
|
|
||||||
import android.os.AsyncTask;
|
import android.os.AsyncTask;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.Editable;
|
|
||||||
import android.text.TextWatcher;
|
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.View.OnClickListener;
|
import android.view.View.OnClickListener;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@@ -16,19 +12,19 @@ import android.view.animation.Animation;
|
|||||||
import android.view.animation.AnimationUtils;
|
import android.view.animation.AnimationUtils;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.AnimRes;
|
import androidx.annotation.AnimRes;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
|
||||||
|
|
||||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar;
|
import org.thoughtcrime.securesms.components.ContactFilterView;
|
||||||
import org.thoughtcrime.securesms.components.ContactFilterToolbar.OnFilterChangedListener;
|
import org.thoughtcrime.securesms.components.ContactFilterView.OnFilterChangedListener;
|
||||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader.DisplayMode;
|
||||||
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
import org.thoughtcrime.securesms.contacts.SelectedContact;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||||
@@ -36,27 +32,25 @@ import org.thoughtcrime.securesms.sms.MessageSender;
|
|||||||
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
import org.thoughtcrime.securesms.sms.OutgoingTextMessage;
|
||||||
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
import org.thoughtcrime.securesms.util.DynamicNoActionBarInviteTheme;
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
|
||||||
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 org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
|
public class InviteActivity extends PassphraseRequiredActivity implements ContactSelectionListFragment.OnContactSelectedListener {
|
||||||
|
|
||||||
private ContactSelectionListFragment contactsFragment;
|
private ContactSelectionListFragment contactsFragment;
|
||||||
private EditText inviteText;
|
private EditText inviteText;
|
||||||
private ViewGroup smsSendFrame;
|
private ViewGroup smsSendFrame;
|
||||||
private Button smsSendButton;
|
private Button smsSendButton;
|
||||||
private Animation slideInAnimation;
|
private Animation slideInAnimation;
|
||||||
private Animation slideOutAnimation;
|
private Animation slideOutAnimation;
|
||||||
private DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
|
private final DynamicTheme dynamicTheme = new DynamicNoActionBarInviteTheme();
|
||||||
private Toolbar primaryToolbar;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPreCreate() {
|
protected void onPreCreate() {
|
||||||
@@ -84,7 +78,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initializeAppBar() {
|
private void initializeAppBar() {
|
||||||
primaryToolbar = findViewById(R.id.toolbar);
|
final Toolbar primaryToolbar = findViewById(R.id.toolbar);
|
||||||
setSupportActionBar(primaryToolbar);
|
setSupportActionBar(primaryToolbar);
|
||||||
|
|
||||||
assert getSupportActionBar() != null;
|
assert getSupportActionBar() != null;
|
||||||
@@ -98,9 +92,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
|
slideOutAnimation = loadAnimation(R.anim.slide_to_bottom);
|
||||||
|
|
||||||
View shareButton = findViewById(R.id.share_button);
|
View shareButton = findViewById(R.id.share_button);
|
||||||
Button smsButton = findViewById(R.id.sms_button);
|
TextView shareText = findViewById(R.id.share_text);
|
||||||
|
View smsButton = findViewById(R.id.sms_button);
|
||||||
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
|
Button smsCancelButton = findViewById(R.id.cancel_sms_button);
|
||||||
ContactFilterToolbar contactFilter = findViewById(R.id.contact_filter);
|
ContactFilterView contactFilter = findViewById(R.id.contact_filter_edit_text);
|
||||||
|
|
||||||
inviteText = findViewById(R.id.invite_text);
|
inviteText = findViewById(R.id.invite_text);
|
||||||
smsSendFrame = findViewById(R.id.sms_send_frame);
|
smsSendFrame = findViewById(R.id.sms_send_frame);
|
||||||
@@ -121,15 +116,14 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
|
smsCancelButton.setOnClickListener(new SmsCancelClickListener());
|
||||||
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
smsSendButton.setOnClickListener(new SmsSendClickListener());
|
||||||
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
contactFilter.setOnFilterChangedListener(new ContactFilterChangedListener());
|
||||||
contactFilter.setNavigationIcon(R.drawable.ic_search_conversation_24);
|
|
||||||
|
|
||||||
if (Util.isDefaultSmsProvider(this)) {
|
if (Util.isDefaultSmsProvider(this)) {
|
||||||
shareButton.setOnClickListener(new ShareClickListener());
|
shareButton.setOnClickListener(new ShareClickListener());
|
||||||
smsButton.setOnClickListener(new SmsClickListener());
|
smsButton.setOnClickListener(new SmsClickListener());
|
||||||
} else {
|
} else {
|
||||||
shareButton.setVisibility(View.GONE);
|
smsButton.setVisibility(View.GONE);
|
||||||
smsButton.setOnClickListener(new ShareClickListener());
|
shareText.setText(R.string.InviteActivity_share);
|
||||||
smsButton.setText(R.string.InviteActivity_share);
|
shareButton.setOnClickListener(new ShareClickListener());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,9 +134,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
updateSmsButtonText(contactsFragment.getSelectedContacts().size() + 1);
|
||||||
return true;
|
callback.accept(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -150,6 +144,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSelectionChanged() {
|
||||||
|
}
|
||||||
|
|
||||||
private void sendSmsInvites() {
|
private void sendSmsInvites() {
|
||||||
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
|
new SendSmsInvitesAsyncTask(this, inviteText.getText().toString())
|
||||||
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
|
.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
|
||||||
@@ -158,9 +156,7 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateSmsButtonText(int count) {
|
private void updateSmsButtonText(int count) {
|
||||||
smsSendButton.setText(getResources().getQuantityString(R.plurals.InviteActivity_send_sms_to_friends,
|
smsSendButton.setText(getResources().getString(R.string.InviteActivity_send_sms, count));
|
||||||
count,
|
|
||||||
count));
|
|
||||||
smsSendButton.setEnabled(count > 0);
|
smsSendButton.setEnabled(count > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,43 +168,21 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override public boolean onSupportNavigateUp() {
|
||||||
|
if (smsSendFrame.getVisibility() == View.VISIBLE) {
|
||||||
|
cancelSmsSelection();
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
return super.onSupportNavigateUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void cancelSmsSelection() {
|
private void cancelSmsSelection() {
|
||||||
setPrimaryColorsToolbarNormal();
|
|
||||||
contactsFragment.reset();
|
contactsFragment.reset();
|
||||||
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
updateSmsButtonText(contactsFragment.getSelectedContacts().size());
|
||||||
ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
|
ViewUtil.animateOut(smsSendFrame, slideOutAnimation, View.GONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void setPrimaryColorsToolbarNormal() {
|
|
||||||
primaryToolbar.setBackgroundColor(0);
|
|
||||||
primaryToolbar.getNavigationIcon().setColorFilter(null);
|
|
||||||
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_primary));
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 23) {
|
|
||||||
WindowUtil.setStatusBarColor(getWindow(), ThemeUtil.getThemedColor(this, android.R.attr.statusBarColor));
|
|
||||||
getWindow().setNavigationBarColor(ThemeUtil.getThemedColor(this, android.R.attr.navigationBarColor));
|
|
||||||
WindowUtil.setLightStatusBarFromTheme(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
WindowUtil.setLightNavigationBarFromTheme(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setPrimaryColorsToolbarForSms() {
|
|
||||||
primaryToolbar.setBackgroundColor(ContextCompat.getColor(this, R.color.core_ultramarine));
|
|
||||||
primaryToolbar.getNavigationIcon().setColorFilter(ContextCompat.getColor(this, R.color.signal_text_toolbar_subtitle), PorterDuff.Mode.SRC_IN);
|
|
||||||
primaryToolbar.setTitleTextColor(ContextCompat.getColor(this, R.color.signal_text_toolbar_title));
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 23) {
|
|
||||||
WindowUtil.setStatusBarColor(getWindow(), ContextCompat.getColor(this, R.color.core_ultramarine));
|
|
||||||
WindowUtil.clearLightStatusBar(getWindow());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= 27) {
|
|
||||||
getWindow().setNavigationBarColor(ContextCompat.getColor(this, R.color.core_ultramarine));
|
|
||||||
WindowUtil.clearLightNavigationBar(getWindow());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ShareClickListener implements OnClickListener {
|
private class ShareClickListener implements OnClickListener {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
@@ -227,7 +201,6 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
private class SmsClickListener implements OnClickListener {
|
private class SmsClickListener implements OnClickListener {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View v) {
|
public void onClick(View v) {
|
||||||
setPrimaryColorsToolbarForSms();
|
|
||||||
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
|
ViewUtil.animateIn(smsSendFrame, slideInAnimation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,10 +252,10 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
|
|||||||
Recipient recipient = Recipient.resolved(recipientId);
|
Recipient recipient = Recipient.resolved(recipientId);
|
||||||
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
|
int subscriptionId = recipient.getDefaultSubscriptionId().or(-1);
|
||||||
|
|
||||||
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null);
|
MessageSender.send(context, new OutgoingTextMessage(recipient, message, subscriptionId), -1L, true, null, null);
|
||||||
|
|
||||||
if (recipient.getContactUri() != null) {
|
if (recipient.getContactUri() != null) {
|
||||||
DatabaseFactory.getRecipientDatabase(context).setHasSentInvite(recipient.getId());
|
SignalDatabase.recipients().setHasSentInvite(recipient.getId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.os.Bundle;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
|
||||||
|
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.util.AppStartup;
|
import org.thoughtcrime.securesms.util.AppStartup;
|
||||||
@@ -17,13 +19,15 @@ 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;
|
||||||
|
|
||||||
public class MainActivity extends PassphraseRequiredActivity {
|
public class MainActivity extends PassphraseRequiredActivity implements VoiceNoteMediaControllerOwner {
|
||||||
|
|
||||||
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
|
public static final int RESULT_CONFIG_CHANGED = Activity.RESULT_FIRST_USER + 901;
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -40,10 +44,12 @@ public class MainActivity extends PassphraseRequiredActivity {
|
|||||||
super.onCreate(savedInstanceState, ready);
|
super.onCreate(savedInstanceState, ready);
|
||||||
setContentView(R.layout.main_activity);
|
setContentView(R.layout.main_activity);
|
||||||
|
|
||||||
|
mediaController = new VoiceNoteMediaController(this);
|
||||||
navigator.onCreate(savedInstanceState);
|
navigator.onCreate(savedInstanceState);
|
||||||
|
|
||||||
handleGroupLinkInIntent(getIntent());
|
handleGroupLinkInIntent(getIntent());
|
||||||
handleProxyInIntent(getIntent());
|
handleProxyInIntent(getIntent());
|
||||||
|
handleSignalMeIntent(getIntent());
|
||||||
|
|
||||||
CachedInflater.from(this).clear();
|
CachedInflater.from(this).clear();
|
||||||
}
|
}
|
||||||
@@ -60,6 +66,7 @@ public class MainActivity extends PassphraseRequiredActivity {
|
|||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
handleGroupLinkInIntent(intent);
|
handleGroupLinkInIntent(intent);
|
||||||
handleProxyInIntent(intent);
|
handleProxyInIntent(intent);
|
||||||
|
handleSignalMeIntent(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -109,4 +116,16 @@ public class MainActivity extends PassphraseRequiredActivity {
|
|||||||
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
|
CommunicationActions.handlePotentialProxyLinkUrl(this, data.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleSignalMeIntent(Intent intent) {
|
||||||
|
Uri data = intent.getData();
|
||||||
|
if (data != null) {
|
||||||
|
CommunicationActions.handlePotentialSignalMeUrl(this, data.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NonNull VoiceNoteMediaController getVoiceNoteMediaController() {
|
||||||
|
return mediaController;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ 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;
|
||||||
|
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
|
||||||
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
|
import org.thoughtcrime.securesms.util.SaveAttachmentTask.Attachment;
|
||||||
import org.thoughtcrime.securesms.util.StorageUtil;
|
import org.thoughtcrime.securesms.util.StorageUtil;
|
||||||
@@ -171,6 +172,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
initializeObservers();
|
initializeObservers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||||
@@ -533,7 +535,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isContentTypeSupported(final String contentType) {
|
public static boolean isContentTypeSupported(final String contentType) {
|
||||||
return contentType != null && (contentType.startsWith("image/") || contentType.startsWith("video/"));
|
return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -567,26 +569,11 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
if (item == 0) {
|
if (item == 0) {
|
||||||
viewPagerListener.onPageSelected(0);
|
viewPagerListener.onPageSelected(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor.registerContentObserver(new ContentObserver(new Handler(getMainLooper())) {
|
|
||||||
@Override
|
|
||||||
public void onChange(boolean selfChange) {
|
|
||||||
onMediaChange();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
mediaNotAvailable();
|
mediaNotAvailable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onMediaChange() {
|
|
||||||
MediaItemAdapter adapter = (MediaItemAdapter) mediaPager.getAdapter();
|
|
||||||
|
|
||||||
if (adapter != null) {
|
|
||||||
adapter.checkMedia(mediaPager.getCurrentItem());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onLoaderReset(@NonNull Loader<Pair<Cursor, Integer>> loader) {
|
public void onLoaderReset(@NonNull Loader<Pair<Cursor, Integer>> loader) {
|
||||||
|
|
||||||
@@ -614,7 +601,10 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
|
|
||||||
if (adapter != null) {
|
if (adapter != null) {
|
||||||
MediaItem item = adapter.getMediaItemFor(position);
|
MediaItem item = adapter.getMediaItemFor(position);
|
||||||
if (item.recipient != null) item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
|
if (item != null && item.recipient != null) {
|
||||||
|
item.recipient.live().observe(MediaPreviewActivity.this, r -> initializeActionBar());
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
|
viewModel.setActiveAlbumRailItem(MediaPreviewActivity.this, position);
|
||||||
initializeActionBar();
|
initializeActionBar();
|
||||||
}
|
}
|
||||||
@@ -627,7 +617,9 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
|
|
||||||
if (adapter != null) {
|
if (adapter != null) {
|
||||||
MediaItem item = adapter.getMediaItemFor(position);
|
MediaItem item = adapter.getMediaItemFor(position);
|
||||||
if (item.recipient != null) item.recipient.live().removeObservers(MediaPreviewActivity.this);
|
if (item != null && item.recipient != null) {
|
||||||
|
item.recipient.live().removeObservers(MediaPreviewActivity.this);
|
||||||
|
}
|
||||||
|
|
||||||
adapter.pause(position);
|
adapter.pause(position);
|
||||||
}
|
}
|
||||||
@@ -677,7 +669,7 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public MediaItem getMediaItemFor(int position) {
|
public @Nullable MediaItem getMediaItemFor(int position) {
|
||||||
return new MediaItem(null, null, null, uri, mediaType, -1, true);
|
return new MediaItem(null, null, null, uri, mediaType, -1, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -700,11 +692,6 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
public boolean hasFragmentFor(int position) {
|
public boolean hasFragmentFor(int position) {
|
||||||
return mediaPreviewFragment != null;
|
return mediaPreviewFragment != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void checkMedia(int currentItem) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
|
private static void anchorMarginsToBottomInsets(@NonNull View viewToAnchor) {
|
||||||
@@ -788,8 +775,15 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
super.destroyItem(container, position, object);
|
super.destroyItem(container, position, object);
|
||||||
}
|
}
|
||||||
|
|
||||||
public MediaItem getMediaItemFor(int position) {
|
public @Nullable MediaItem getMediaItemFor(int position) {
|
||||||
cursor.moveToPosition(getCursorPosition(position));
|
int cursorPosition = getCursorPosition(position);
|
||||||
|
|
||||||
|
if (cursor.isClosed() || cursorPosition < 0) {
|
||||||
|
Log.w(TAG, "Invalid cursor state! Closed: " + cursor.isClosed() + " Position: " + cursorPosition);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor.moveToPosition(cursorPosition);
|
||||||
|
|
||||||
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
|
MediaRecord mediaRecord = MediaRecord.from(context, cursor);
|
||||||
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
DatabaseAttachment attachment = Objects.requireNonNull(mediaRecord.getAttachment());
|
||||||
@@ -823,14 +817,6 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
return mediaFragments.containsKey(position);
|
return mediaFragments.containsKey(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void checkMedia(int position) {
|
|
||||||
MediaPreviewFragment fragment = mediaFragments.get(position);
|
|
||||||
if (fragment != null) {
|
|
||||||
fragment.checkMediaStillAvailable();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int getCursorPosition(int position) {
|
private int getCursorPosition(int position) {
|
||||||
if (leftIsRecent) return position;
|
if (leftIsRecent) return position;
|
||||||
else return cursor.getCount() - 1 - position;
|
else return cursor.getCount() - 1 - position;
|
||||||
@@ -865,10 +851,9 @@ public final class MediaPreviewActivity extends PassphraseRequiredActivity
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface MediaItemAdapter {
|
interface MediaItemAdapter {
|
||||||
MediaItem getMediaItemFor(int position);
|
@Nullable MediaItem getMediaItemFor(int position);
|
||||||
void pause(int position);
|
void pause(int position);
|
||||||
@Nullable View getPlaybackControls(int position);
|
@Nullable View getPlaybackControls(int position);
|
||||||
boolean hasFragmentFor(int position);
|
boolean hasFragmentFor(int position);
|
||||||
void checkMedia(int currentItem);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
public class MuteDialog extends AlertDialog {
|
public class MuteDialog extends AlertDialog {
|
||||||
@@ -29,24 +31,21 @@ public class MuteDialog extends AlertDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
public static void show(final Context context, final @NonNull MuteSelectionListener listener, @Nullable Runnable cancelListener) {
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(context);
|
AlertDialog.Builder builder = new MaterialAlertDialogBuilder(context);
|
||||||
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
builder.setTitle(R.string.MuteDialog_mute_notifications);
|
||||||
builder.setItems(R.array.mute_durations, new DialogInterface.OnClickListener() {
|
builder.setItems(R.array.mute_durations, (dialog, which) -> {
|
||||||
@Override
|
final long muteUntil;
|
||||||
public void onClick(DialogInterface dialog, final int which) {
|
|
||||||
final long muteUntil;
|
|
||||||
|
|
||||||
switch (which) {
|
switch (which) {
|
||||||
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
case 0: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||||
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
|
case 1: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(8); break;
|
||||||
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
|
case 2: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(1); break;
|
||||||
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
|
case 3: muteUntil = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(7); break;
|
||||||
case 4: muteUntil = Long.MAX_VALUE; break;
|
case 4: muteUntil = Long.MAX_VALUE; break;
|
||||||
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
default: muteUntil = System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1); break;
|
||||||
}
|
|
||||||
|
|
||||||
listener.onMuted(muteUntil);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listener.onMuted(muteUntil);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (cancelListener != null) {
|
if (cancelListener != null) {
|
||||||
|
|||||||
@@ -26,17 +26,18 @@ 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.DirectoryHelper;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity;
|
||||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||||
|
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.TextSecurePreferences;
|
|
||||||
import org.thoughtcrime.securesms.util.concurrent.SimpleTask;
|
import org.thoughtcrime.securesms.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 org.whispersystems.libsignal.util.guava.Optional;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity container for starting a new conversation.
|
* Activity container for starting a new conversation.
|
||||||
@@ -56,16 +57,17 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||||||
super.onCreate(bundle, ready);
|
super.onCreate(bundle, ready);
|
||||||
assert getSupportActionBar() != null;
|
assert getSupportActionBar() != null;
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||||
|
getSupportActionBar().setTitle(R.string.NewConversationActivity__new_message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onBeforeContactSelected(Optional<RecipientId> recipientId, String number) {
|
public void onBeforeContactSelected(Optional<RecipientId> recipientId, String number, Consumer<Boolean> callback) {
|
||||||
if (recipientId.isPresent()) {
|
if (recipientId.isPresent()) {
|
||||||
launch(Recipient.resolved(recipientId.get()));
|
launch(Recipient.resolved(recipientId.get()));
|
||||||
} else {
|
} else {
|
||||||
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
Log.i(TAG, "[onContactSelected] Maybe creating a new recipient.");
|
||||||
|
|
||||||
if (TextSecurePreferences.isPushRegistered(this) && NetworkConstraint.isMet(this)) {
|
if (SignalStore.account().isRegistered() && NetworkConstraint.isMet(this)) {
|
||||||
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
|
Log.i(TAG, "[onContactSelected] Doing contact refresh.");
|
||||||
|
|
||||||
AlertDialog progress = SimpleProgressDialog.show(this);
|
AlertDialog progress = SimpleProgressDialog.show(this);
|
||||||
@@ -73,7 +75,7 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||||||
SimpleTask.run(getLifecycle(), () -> {
|
SimpleTask.run(getLifecycle(), () -> {
|
||||||
Recipient resolved = Recipient.external(this, number);
|
Recipient resolved = Recipient.external(this, number);
|
||||||
|
|
||||||
if (!resolved.isRegistered() || !resolved.hasUuid()) {
|
if (!resolved.isRegistered() || !resolved.hasAci()) {
|
||||||
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);
|
DirectoryHelper.refreshDirectoryFor(this, resolved, false);
|
||||||
@@ -93,11 +95,15 @@ public class NewConversationActivity extends ContactSelectionActivity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
callback.accept(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSelectionChanged() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void launch(Recipient recipient) {
|
private void launch(Recipient recipient) {
|
||||||
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
|
long existingThread = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId());
|
||||||
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
|
Intent intent = ConversationIntents.createBuilder(this, recipient.getId(), existingThread)
|
||||||
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
|
.withDraftText(getIntent().getStringExtra(Intent.EXTRA_TEXT))
|
||||||
.withDataUri(getIntent().getData())
|
.withDataUri(getIntent().getData())
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.thoughtcrime.securesms
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BitmapTransformation which overlays the given bitmap with the given color.
|
||||||
|
*/
|
||||||
|
class OverlayTransformation(
|
||||||
|
@ColorInt private val color: Int
|
||||||
|
) : BitmapTransformation() {
|
||||||
|
|
||||||
|
private val id = "${OverlayTransformation::class.java.name}$color"
|
||||||
|
|
||||||
|
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||||
|
messageDigest.update(id.toByteArray(CHARSET))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
|
||||||
|
val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(outBitmap)
|
||||||
|
|
||||||
|
canvas.drawBitmap(toTransform, 0f, 0f, null)
|
||||||
|
canvas.drawColor(color)
|
||||||
|
|
||||||
|
return outBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return (other as? OverlayTransformation)?.color == color
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return id.hashCode()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,6 @@ import android.view.MenuInflater;
|
|||||||
import android.view.MenuItem;
|
import android.view.MenuItem;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.View.OnClickListener;
|
import android.view.View.OnClickListener;
|
||||||
import android.view.WindowManager;
|
|
||||||
import android.view.animation.Animation;
|
import android.view.animation.Animation;
|
||||||
import android.view.animation.BounceInterpolator;
|
import android.view.animation.BounceInterpolator;
|
||||||
import android.view.animation.TranslateAnimation;
|
import android.view.animation.TranslateAnimation;
|
||||||
@@ -51,6 +50,7 @@ import androidx.biometric.BiometricManager;
|
|||||||
import androidx.biometric.BiometricManager.Authenticators;
|
import androidx.biometric.BiometricManager.Authenticators;
|
||||||
import androidx.biometric.BiometricPrompt;
|
import androidx.biometric.BiometricPrompt;
|
||||||
|
|
||||||
|
import org.signal.core.util.ThreadUtil;
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
|
||||||
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
import org.thoughtcrime.securesms.components.AnimatingToggle;
|
||||||
@@ -98,13 +98,16 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
|||||||
private boolean hadFailure;
|
private boolean hadFailure;
|
||||||
private boolean alreadyShown;
|
private boolean alreadyShown;
|
||||||
|
|
||||||
|
private final Runnable resumeScreenLockRunnable = () -> {
|
||||||
|
resumeScreenLock(!alreadyShown);
|
||||||
|
alreadyShown = true;
|
||||||
|
};
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
Log.i(TAG, "onCreate()");
|
Log.i(TAG, "onCreate()");
|
||||||
dynamicTheme.onCreate(this);
|
dynamicTheme.onCreate(this);
|
||||||
dynamicLanguage.onCreate(this);
|
dynamicLanguage.onCreate(this);
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
|
|
||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
|
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
setContentView(R.layout.prompt_passphrase_activity);
|
setContentView(R.layout.prompt_passphrase_activity);
|
||||||
@@ -129,11 +132,20 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
|||||||
setLockTypeVisibility();
|
setLockTypeVisibility();
|
||||||
|
|
||||||
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
|
if (TextSecurePreferences.isScreenLockEnabled(this) && !authenticated && !hadFailure) {
|
||||||
resumeScreenLock(!alreadyShown);
|
ThreadUtil.postToMain(resumeScreenLockRunnable);
|
||||||
alreadyShown = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hadFailure = false;
|
hadFailure = false;
|
||||||
|
|
||||||
|
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
||||||
|
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPause() {
|
||||||
|
super.onPause();
|
||||||
|
ThreadUtil.cancelRunnableOnMain(resumeScreenLockRunnable);
|
||||||
|
biometricPrompt.cancelAuthentication();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -388,9 +400,6 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onAnimationEnd(Animator animation) {
|
public void onAnimationEnd(Animator animation) {
|
||||||
handleAuthenticated();
|
handleAuthenticated();
|
||||||
|
|
||||||
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
|
||||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
|
|
||||||
}
|
}
|
||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
@@ -412,7 +421,7 @@ public class PassphrasePromptActivity extends PassphraseActivity {
|
|||||||
@Override
|
@Override
|
||||||
public void onAnimationEnd(Animation animation) {
|
public void onAnimationEnd(Animation animation) {
|
||||||
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
fingerprintPrompt.setImageResource(R.drawable.ic_fingerprint_white_48dp);
|
||||||
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.core_ultramarine), PorterDuff.Mode.SRC_IN);
|
fingerprintPrompt.getBackground().setColorFilter(getResources().getColor(R.color.signal_accent_primary), PorterDuff.Mode.SRC_IN);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.greenrobot.eventbus.EventBus;
|
|||||||
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.devicetransfer.TransferStatus;
|
import org.signal.devicetransfer.TransferStatus;
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.changenumber.ChangeNumberLockActivity;
|
||||||
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
import org.thoughtcrime.securesms.crypto.MasterSecretUtil;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity;
|
||||||
@@ -50,6 +51,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
private static final int STATE_CREATE_SIGNAL_PIN = 7;
|
private static final int STATE_CREATE_SIGNAL_PIN = 7;
|
||||||
private static final int STATE_TRANSFER_ONGOING = 8;
|
private static final int STATE_TRANSFER_ONGOING = 8;
|
||||||
private static final int STATE_TRANSFER_LOCKED = 9;
|
private static final int STATE_TRANSFER_LOCKED = 9;
|
||||||
|
private static final int STATE_CHANGE_NUMBER_LOCK = 10;
|
||||||
|
|
||||||
private SignalServiceNetworkAccess networkAccess;
|
private SignalServiceNetworkAccess networkAccess;
|
||||||
private BroadcastReceiver clearKeyReceiver;
|
private BroadcastReceiver clearKeyReceiver;
|
||||||
@@ -58,7 +60,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
protected final void onCreate(Bundle savedInstanceState) {
|
protected final void onCreate(Bundle savedInstanceState) {
|
||||||
Tracer.getInstance().start(Log.tag(getClass()) + "#onCreate()");
|
Tracer.getInstance().start(Log.tag(getClass()) + "#onCreate()");
|
||||||
AppStartup.getInstance().onCriticalRenderEventStart();
|
AppStartup.getInstance().onCriticalRenderEventStart();
|
||||||
this.networkAccess = new SignalServiceNetworkAccess(this);
|
this.networkAccess = ApplicationDependencies.getSignalServiceNetworkAccess();
|
||||||
onPreCreate();
|
onPreCreate();
|
||||||
|
|
||||||
final boolean locked = KeyCachingService.isLocked(this);
|
final boolean locked = KeyCachingService.isLocked(this);
|
||||||
@@ -82,7 +84,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
protected void onResume() {
|
protected void onResume() {
|
||||||
super.onResume();
|
super.onResume();
|
||||||
|
|
||||||
if (networkAccess.isCensored(this)) {
|
if (networkAccess.isCensored()) {
|
||||||
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
|
ApplicationDependencies.getJobManager().add(new PushNotificationReceiveJob());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +155,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
|
case STATE_CREATE_PROFILE_NAME: return getCreateProfileNameIntent();
|
||||||
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
case STATE_TRANSFER_ONGOING: return getOldDeviceTransferIntent();
|
||||||
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
case STATE_TRANSFER_LOCKED: return getOldDeviceTransferLockedIntent();
|
||||||
|
case STATE_CHANGE_NUMBER_LOCK: return getChangeNumberLockIntent();
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,6 +179,8 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
return STATE_TRANSFER_ONGOING;
|
return STATE_TRANSFER_ONGOING;
|
||||||
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
|
||||||
return STATE_TRANSFER_LOCKED;
|
return STATE_TRANSFER_LOCKED;
|
||||||
|
} else if (SignalStore.misc().isChangeNumberLocked() && getClass() != ChangeNumberLockActivity.class) {
|
||||||
|
return STATE_CHANGE_NUMBER_LOCK;
|
||||||
} else {
|
} else {
|
||||||
return STATE_NORMAL;
|
return STATE_NORMAL;
|
||||||
}
|
}
|
||||||
@@ -243,6 +248,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
|
|||||||
return MainActivity.clearTop(this);
|
return MainActivity.clearTop(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Intent getChangeNumberLockIntent() {
|
||||||
|
return ChangeNumberLockActivity.createIntent(this);
|
||||||
|
}
|
||||||
|
|
||||||
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
|
private Intent getRoutedIntent(Class<?> destination, @Nullable Intent nextIntent) {
|
||||||
final Intent intent = new Intent(this, destination);
|
final Intent intent = new Intent(this, destination);
|
||||||
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
|
if (nextIntent != null) intent.putExtra("next_intent", nextIntent);
|
||||||
|
|||||||
@@ -65,4 +65,8 @@ public class PushContactSelectionActivity extends ContactSelectionActivity {
|
|||||||
setResult(RESULT_OK, resultIntent);
|
setResult(RESULT_OK, resultIntent);
|
||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSelectionChanged() {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Device hardware capability lists.
|
|
||||||
* <p>
|
|
||||||
* Moved outside of ApplicationContext as the indirection was important for API19 support with desugaring: https://issuetracker.google.com/issues/183419297
|
|
||||||
*/
|
|
||||||
final class RtcDeviceLists {
|
|
||||||
|
|
||||||
private RtcDeviceLists() {}
|
|
||||||
|
|
||||||
static Set<String> hardwareAECBlockList() {
|
|
||||||
return new HashSet<String>() {{
|
|
||||||
add("Pixel");
|
|
||||||
add("Pixel XL");
|
|
||||||
add("Moto G5");
|
|
||||||
add("Moto G (5S) Plus");
|
|
||||||
add("Moto G4");
|
|
||||||
add("TA-1053");
|
|
||||||
add("Mi A1");
|
|
||||||
add("Mi A2");
|
|
||||||
add("E5823"); // Sony z5 compact
|
|
||||||
add("Redmi Note 5");
|
|
||||||
add("FP2"); // Fairphone FP2
|
|
||||||
add("MI 5");
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
static Set<String> openSlEsAllowList() {
|
|
||||||
return new HashSet<String>() {{
|
|
||||||
add("Pixel");
|
|
||||||
add("Pixel XL");
|
|
||||||
}};
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean hardwareAECBlocked() {
|
|
||||||
return hardwareAECBlockList().contains(Build.MODEL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean openSLESAllowed() {
|
|
||||||
return openSlEsAllowList().contains(Build.MODEL);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import androidx.annotation.NonNull;
|
|||||||
|
|
||||||
import org.signal.core.util.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
import org.thoughtcrime.securesms.conversation.ConversationIntents;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||||
import org.thoughtcrime.securesms.util.Rfc5724Uri;
|
import org.thoughtcrime.securesms.util.Rfc5724Uri;
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ public class SmsSendtoActivity extends Activity {
|
|||||||
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
|
Toast.makeText(this, R.string.ConversationActivity_specify_recipient, Toast.LENGTH_LONG).show();
|
||||||
} else {
|
} else {
|
||||||
Recipient recipient = Recipient.external(this, destination.getDestination());
|
Recipient recipient = Recipient.external(this, destination.getDestination());
|
||||||
long threadId = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient.getId());
|
long threadId = SignalDatabase.threads().getThreadIdIfExistsFor(recipient.getId());
|
||||||
|
|
||||||
nextIntent = ConversationIntents.createBuilder(this, recipient.getId(), threadId)
|
nextIntent = ConversationIntents.createBuilder(this, recipient.getId(), threadId)
|
||||||
.withDraftText(destination.getBody())
|
.withDraftText(destination.getBody())
|
||||||
|
|||||||
@@ -1,693 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2016-2017 Open Whisper Systems
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.thoughtcrime.securesms;
|
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.animation.TypeEvaluator;
|
|
||||||
import android.animation.ValueAnimator;
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.ActivityNotFoundException;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.res.Configuration;
|
|
||||||
import android.graphics.Bitmap;
|
|
||||||
import android.graphics.BitmapFactory;
|
|
||||||
import android.graphics.Canvas;
|
|
||||||
import android.graphics.PorterDuff;
|
|
||||||
import android.graphics.drawable.BitmapDrawable;
|
|
||||||
import android.graphics.drawable.ColorDrawable;
|
|
||||||
import android.os.AsyncTask;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Vibrator;
|
|
||||||
import android.text.Html;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.text.method.LinkMovementMethod;
|
|
||||||
import android.view.ContextMenu;
|
|
||||||
import android.view.ContextMenu.ContextMenuInfo;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.view.animation.Animation;
|
|
||||||
import android.view.animation.AnticipateInterpolator;
|
|
||||||
import android.view.animation.OvershootInterpolator;
|
|
||||||
import android.view.animation.ScaleAnimation;
|
|
||||||
import android.widget.CompoundButton;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes;
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.widget.SwitchCompat;
|
|
||||||
import androidx.fragment.app.Fragment;
|
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
|
||||||
|
|
||||||
import org.signal.core.util.ThreadUtil;
|
|
||||||
import org.signal.core.util.concurrent.SignalExecutors;
|
|
||||||
import org.signal.core.util.logging.Log;
|
|
||||||
import org.thoughtcrime.securesms.color.MaterialColor;
|
|
||||||
import org.thoughtcrime.securesms.components.camera.CameraView;
|
|
||||||
import org.thoughtcrime.securesms.crypto.DatabaseSessionLock;
|
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable;
|
|
||||||
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil;
|
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
|
||||||
import org.thoughtcrime.securesms.database.IdentityDatabase;
|
|
||||||
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
|
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
|
||||||
import org.thoughtcrime.securesms.jobs.MultiDeviceVerifiedUpdateJob;
|
|
||||||
import org.thoughtcrime.securesms.permissions.Permissions;
|
|
||||||
import org.thoughtcrime.securesms.qr.QrCode;
|
|
||||||
import org.thoughtcrime.securesms.qr.ScanListener;
|
|
||||||
import org.thoughtcrime.securesms.qr.ScanningThread;
|
|
||||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
|
||||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
|
||||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
|
||||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
|
||||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
|
||||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
|
||||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
|
||||||
import org.thoughtcrime.securesms.util.Util;
|
|
||||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
|
||||||
import org.thoughtcrime.securesms.util.WindowUtil;
|
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
|
||||||
import org.whispersystems.libsignal.fingerprint.Fingerprint;
|
|
||||||
import org.whispersystems.libsignal.fingerprint.FingerprintParsingException;
|
|
||||||
import org.whispersystems.libsignal.fingerprint.FingerprintVersionMismatchException;
|
|
||||||
import org.whispersystems.libsignal.fingerprint.NumericFingerprintGenerator;
|
|
||||||
import org.whispersystems.signalservice.api.SignalSessionLock;
|
|
||||||
import org.whispersystems.signalservice.api.util.UuidUtil;
|
|
||||||
|
|
||||||
import java.io.UnsupportedEncodingException;
|
|
||||||
import java.nio.charset.Charset;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity for verifying identity keys.
|
|
||||||
*
|
|
||||||
* @author Moxie Marlinspike
|
|
||||||
*/
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
public class VerifyIdentityActivity extends PassphraseRequiredActivity implements ScanListener, View.OnClickListener {
|
|
||||||
|
|
||||||
private static final String TAG = Log.tag(VerifyIdentityActivity.class);
|
|
||||||
|
|
||||||
private static final String RECIPIENT_EXTRA = "recipient_id";
|
|
||||||
private static final String IDENTITY_EXTRA = "recipient_identity";
|
|
||||||
private static final String VERIFIED_EXTRA = "verified_state";
|
|
||||||
|
|
||||||
private final DynamicTheme dynamicTheme = new DynamicTheme();
|
|
||||||
|
|
||||||
private final VerifyDisplayFragment displayFragment = new VerifyDisplayFragment();
|
|
||||||
private final VerifyScanFragment scanFragment = new VerifyScanFragment();
|
|
||||||
|
|
||||||
public static Intent newIntent(@NonNull Context context,
|
|
||||||
@NonNull IdentityDatabase.IdentityRecord identityRecord)
|
|
||||||
{
|
|
||||||
return newIntent(context,
|
|
||||||
identityRecord.getRecipientId(),
|
|
||||||
identityRecord.getIdentityKey(),
|
|
||||||
identityRecord.getVerifiedStatus() == IdentityDatabase.VerifiedStatus.VERIFIED);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Intent newIntent(@NonNull Context context,
|
|
||||||
@NonNull IdentityDatabase.IdentityRecord identityRecord,
|
|
||||||
boolean verified)
|
|
||||||
{
|
|
||||||
return newIntent(context,
|
|
||||||
identityRecord.getRecipientId(),
|
|
||||||
identityRecord.getIdentityKey(),
|
|
||||||
verified);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Intent newIntent(@NonNull Context context,
|
|
||||||
@NonNull RecipientId recipientId,
|
|
||||||
@NonNull IdentityKey identityKey,
|
|
||||||
boolean verified)
|
|
||||||
{
|
|
||||||
Intent intent = new Intent(context, VerifyIdentityActivity.class);
|
|
||||||
|
|
||||||
intent.putExtra(RECIPIENT_EXTRA, recipientId);
|
|
||||||
intent.putExtra(IDENTITY_EXTRA, new IdentityKeyParcelable(identityKey));
|
|
||||||
intent.putExtra(VERIFIED_EXTRA, verified);
|
|
||||||
|
|
||||||
return intent;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPreCreate() {
|
|
||||||
dynamicTheme.onCreate(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle state, boolean ready) {
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
getSupportActionBar().setTitle(R.string.AndroidManifest__verify_safety_number);
|
|
||||||
|
|
||||||
Bundle extras = new Bundle();
|
|
||||||
extras.putParcelable(VerifyDisplayFragment.RECIPIENT_ID, getIntent().getParcelableExtra(RECIPIENT_EXTRA));
|
|
||||||
extras.putParcelable(VerifyDisplayFragment.REMOTE_IDENTITY, getIntent().getParcelableExtra(IDENTITY_EXTRA));
|
|
||||||
extras.putParcelable(VerifyDisplayFragment.LOCAL_IDENTITY, new IdentityKeyParcelable(IdentityKeyUtil.getIdentityKey(this)));
|
|
||||||
extras.putString(VerifyDisplayFragment.LOCAL_NUMBER, TextSecurePreferences.getLocalNumber(this));
|
|
||||||
extras.putBoolean(VerifyDisplayFragment.VERIFIED_STATE, getIntent().getBooleanExtra(VERIFIED_EXTRA, false));
|
|
||||||
|
|
||||||
scanFragment.setScanListener(this);
|
|
||||||
displayFragment.setClickListener(this);
|
|
||||||
|
|
||||||
initFragment(android.R.id.content, displayFragment, Locale.getDefault(), extras);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case android.R.id.home: finish(); return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onQrDataFound(final String data) {
|
|
||||||
ThreadUtil.runOnMain(() -> {
|
|
||||||
((Vibrator)getSystemService(Context.VIBRATOR_SERVICE)).vibrate(50);
|
|
||||||
|
|
||||||
getSupportFragmentManager().popBackStack();
|
|
||||||
displayFragment.setScannedFingerprint(data);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClick(View v) {
|
|
||||||
Permissions.with(this)
|
|
||||||
.request(Manifest.permission.CAMERA)
|
|
||||||
.ifNecessary()
|
|
||||||
.withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied))
|
|
||||||
.onAllGranted(() -> {
|
|
||||||
FragmentTransaction transaction = getSupportFragmentManager().beginTransaction();
|
|
||||||
transaction.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom,
|
|
||||||
R.anim.slide_from_bottom, R.anim.slide_to_top);
|
|
||||||
|
|
||||||
transaction.replace(android.R.id.content, scanFragment)
|
|
||||||
.addToBackStack(null)
|
|
||||||
.commitAllowingStateLoss();
|
|
||||||
})
|
|
||||||
.onAnyDenied(() -> Toast.makeText(this, R.string.VerifyIdentityActivity_unable_to_scan_qr_code_without_camera_permission, Toast.LENGTH_LONG).show())
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
|
||||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class VerifyDisplayFragment extends Fragment implements CompoundButton.OnCheckedChangeListener {
|
|
||||||
|
|
||||||
public static final String RECIPIENT_ID = "recipient_id";
|
|
||||||
public static final String REMOTE_NUMBER = "remote_number";
|
|
||||||
public static final String REMOTE_IDENTITY = "remote_identity";
|
|
||||||
public static final String LOCAL_IDENTITY = "local_identity";
|
|
||||||
public static final String LOCAL_NUMBER = "local_number";
|
|
||||||
public static final String VERIFIED_STATE = "verified_state";
|
|
||||||
|
|
||||||
private LiveRecipient recipient;
|
|
||||||
private IdentityKey localIdentity;
|
|
||||||
private IdentityKey remoteIdentity;
|
|
||||||
private Fingerprint fingerprint;
|
|
||||||
|
|
||||||
private View container;
|
|
||||||
private View numbersContainer;
|
|
||||||
private ImageView qrCode;
|
|
||||||
private ImageView qrVerified;
|
|
||||||
private TextView tapLabel;
|
|
||||||
private TextView description;
|
|
||||||
private View.OnClickListener clickListener;
|
|
||||||
private SwitchCompat verified;
|
|
||||||
|
|
||||||
private TextView[] codes = new TextView[12];
|
|
||||||
private boolean animateSuccessOnDraw = false;
|
|
||||||
private boolean animateFailureOnDraw = false;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
|
||||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_display_fragment);
|
|
||||||
this.numbersContainer = container.findViewById(R.id.number_table);
|
|
||||||
this.qrCode = container.findViewById(R.id.qr_code);
|
|
||||||
this.verified = container.findViewById(R.id.verified_switch);
|
|
||||||
this.qrVerified = container.findViewById(R.id.qr_verified);
|
|
||||||
this.description = container.findViewById(R.id.description);
|
|
||||||
this.tapLabel = container.findViewById(R.id.tap_label);
|
|
||||||
this.codes[0] = container.findViewById(R.id.code_first);
|
|
||||||
this.codes[1] = container.findViewById(R.id.code_second);
|
|
||||||
this.codes[2] = container.findViewById(R.id.code_third);
|
|
||||||
this.codes[3] = container.findViewById(R.id.code_fourth);
|
|
||||||
this.codes[4] = container.findViewById(R.id.code_fifth);
|
|
||||||
this.codes[5] = container.findViewById(R.id.code_sixth);
|
|
||||||
this.codes[6] = container.findViewById(R.id.code_seventh);
|
|
||||||
this.codes[7] = container.findViewById(R.id.code_eighth);
|
|
||||||
this.codes[8] = container.findViewById(R.id.code_ninth);
|
|
||||||
this.codes[9] = container.findViewById(R.id.code_tenth);
|
|
||||||
this.codes[10] = container.findViewById(R.id.code_eleventh);
|
|
||||||
this.codes[11] = container.findViewById(R.id.code_twelth);
|
|
||||||
|
|
||||||
this.qrCode.setOnClickListener(clickListener);
|
|
||||||
this.registerForContextMenu(numbersContainer);
|
|
||||||
|
|
||||||
this.verified.setChecked(getArguments().getBoolean(VERIFIED_STATE, false));
|
|
||||||
this.verified.setOnCheckedChangeListener(this);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle bundle) {
|
|
||||||
super.onCreate(bundle);
|
|
||||||
|
|
||||||
RecipientId recipientId = getArguments().getParcelable(RECIPIENT_ID);
|
|
||||||
IdentityKeyParcelable localIdentityParcelable = getArguments().getParcelable(LOCAL_IDENTITY);
|
|
||||||
IdentityKeyParcelable remoteIdentityParcelable = getArguments().getParcelable(REMOTE_IDENTITY);
|
|
||||||
|
|
||||||
if (recipientId == null) throw new AssertionError("RecipientId required");
|
|
||||||
if (localIdentityParcelable == null) throw new AssertionError("local identity required");
|
|
||||||
if (remoteIdentityParcelable == null) throw new AssertionError("remote identity required");
|
|
||||||
|
|
||||||
this.localIdentity = localIdentityParcelable.get();
|
|
||||||
this.recipient = Recipient.live(recipientId);
|
|
||||||
this.remoteIdentity = remoteIdentityParcelable.get();
|
|
||||||
|
|
||||||
int version;
|
|
||||||
byte[] localId;
|
|
||||||
byte[] remoteId;
|
|
||||||
|
|
||||||
//noinspection WrongThread
|
|
||||||
Recipient resolved = recipient.resolve();
|
|
||||||
|
|
||||||
if (FeatureFlags.verifyV2() && resolved.getUuid().isPresent()) {
|
|
||||||
Log.i(TAG, "Using UUID (version 2).");
|
|
||||||
version = 2;
|
|
||||||
localId = UuidUtil.toByteArray(TextSecurePreferences.getLocalUuid(requireContext()));
|
|
||||||
remoteId = UuidUtil.toByteArray(resolved.getUuid().get());
|
|
||||||
} else if (!FeatureFlags.verifyV2() && resolved.getE164().isPresent()) {
|
|
||||||
Log.i(TAG, "Using E164 (version 1).");
|
|
||||||
version = 1;
|
|
||||||
localId = TextSecurePreferences.getLocalNumber(requireContext()).getBytes();
|
|
||||||
remoteId = resolved.requireE164().getBytes();
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, String.format(Locale.ENGLISH, "Could not show proper verification! verifyV2: %s, hasUuid: %s, hasE164: %s", FeatureFlags.verifyV2(), resolved.getUuid().isPresent(), resolved.getE164().isPresent()));
|
|
||||||
new AlertDialog.Builder(requireContext())
|
|
||||||
.setMessage(getString(R.string.VerifyIdentityActivity_you_must_first_exchange_messages_in_order_to_view, resolved.getDisplayName(requireContext())))
|
|
||||||
.setPositiveButton(android.R.string.ok, (dialog, which) -> requireActivity().finish())
|
|
||||||
.setOnDismissListener(dialog -> requireActivity().finish())
|
|
||||||
.show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.recipient.observe(this, this::setRecipientText);
|
|
||||||
|
|
||||||
new AsyncTask<Void, Void, Fingerprint>() {
|
|
||||||
@Override
|
|
||||||
protected Fingerprint doInBackground(Void... params) {
|
|
||||||
return new NumericFingerprintGenerator(5200).createFor(version,
|
|
||||||
localId, localIdentity,
|
|
||||||
remoteId, remoteIdentity);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPostExecute(Fingerprint fingerprint) {
|
|
||||||
VerifyDisplayFragment.this.fingerprint = fingerprint;
|
|
||||||
setFingerprintViews(fingerprint, true);
|
|
||||||
getActivity().supportInvalidateOptionsMenu();
|
|
||||||
}
|
|
||||||
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
|
|
||||||
|
|
||||||
setHasOptionsMenu(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
|
|
||||||
setRecipientText(recipient.get());
|
|
||||||
|
|
||||||
if (fingerprint != null) {
|
|
||||||
setFingerprintViews(fingerprint, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (animateSuccessOnDraw) {
|
|
||||||
animateSuccessOnDraw = false;
|
|
||||||
animateVerifiedSuccess();
|
|
||||||
} else if (animateFailureOnDraw) {
|
|
||||||
animateFailureOnDraw = false;
|
|
||||||
animateVerifiedFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateContextMenu(ContextMenu menu, View view,
|
|
||||||
ContextMenuInfo menuInfo)
|
|
||||||
{
|
|
||||||
super.onCreateContextMenu(menu, view, menuInfo);
|
|
||||||
|
|
||||||
if (fingerprint != null) {
|
|
||||||
MenuInflater inflater = getActivity().getMenuInflater();
|
|
||||||
inflater.inflate(R.menu.verify_display_fragment_context_menu, menu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onContextItemSelected(MenuItem item) {
|
|
||||||
if (fingerprint == null) return super.onContextItemSelected(item);
|
|
||||||
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case R.id.menu_copy: handleCopyToClipboard(fingerprint, codes.length); return true;
|
|
||||||
case R.id.menu_compare: handleCompareWithClipboard(fingerprint); return true;
|
|
||||||
default: return super.onContextItemSelected(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
|
|
||||||
super.onCreateOptionsMenu(menu, inflater);
|
|
||||||
|
|
||||||
if (fingerprint != null) {
|
|
||||||
inflater.inflate(R.menu.verify_identity, menu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case R.id.verify_identity__share: handleShare(fingerprint, codes.length); return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setScannedFingerprint(String scanned) {
|
|
||||||
try {
|
|
||||||
if (fingerprint.getScannableFingerprint().compareTo(scanned.getBytes("ISO-8859-1"))) {
|
|
||||||
this.animateSuccessOnDraw = true;
|
|
||||||
} else {
|
|
||||||
this.animateFailureOnDraw = true;
|
|
||||||
}
|
|
||||||
} catch (FingerprintVersionMismatchException e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
if (e.getOurVersion() < e.getTheirVersion()) {
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_a_newer_version_of_Signal, Toast.LENGTH_LONG).show();
|
|
||||||
} else {
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_your_contact_is_running_an_old_version_of_signal, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.w(TAG, e);
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_the_scanned_qr_code_is_not_a_correctly_formatted_safety_number, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setClickListener(View.OnClickListener listener) {
|
|
||||||
this.clickListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
private @NonNull String getFormattedSafetyNumbers(@NonNull Fingerprint fingerprint, int segmentCount) {
|
|
||||||
String[] segments = getSegments(fingerprint, segmentCount);
|
|
||||||
StringBuilder result = new StringBuilder();
|
|
||||||
|
|
||||||
for (int i = 0; i < segments.length; i++) {
|
|
||||||
result.append(segments[i]);
|
|
||||||
|
|
||||||
if (i != segments.length - 1) {
|
|
||||||
if (((i+1) % 4) == 0) result.append('\n');
|
|
||||||
else result.append(' ');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleCopyToClipboard(Fingerprint fingerprint, int segmentCount) {
|
|
||||||
Util.writeTextToClipboard(getActivity(), getFormattedSafetyNumbers(fingerprint, segmentCount));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleCompareWithClipboard(Fingerprint fingerprint) {
|
|
||||||
String clipboardData = Util.readTextFromClipboard(getActivity());
|
|
||||||
|
|
||||||
if (clipboardData == null) {
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String numericClipboardData = clipboardData.replaceAll("\\D", "");
|
|
||||||
|
|
||||||
if (TextUtils.isEmpty(numericClipboardData) || numericClipboardData.length() != 60) {
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_safety_number_to_compare_was_found_in_the_clipboard, Toast.LENGTH_LONG).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fingerprint.getDisplayableFingerprint().getDisplayText().equals(numericClipboardData)) {
|
|
||||||
animateVerifiedSuccess();
|
|
||||||
} else {
|
|
||||||
animateVerifiedFailure();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleShare(@NonNull Fingerprint fingerprint, int segmentCount) {
|
|
||||||
String shareString =
|
|
||||||
getString(R.string.VerifyIdentityActivity_our_signal_safety_number) + "\n" +
|
|
||||||
getFormattedSafetyNumbers(fingerprint, segmentCount) + "\n";
|
|
||||||
|
|
||||||
Intent intent = new Intent();
|
|
||||||
intent.setAction(Intent.ACTION_SEND);
|
|
||||||
intent.putExtra(Intent.EXTRA_TEXT, shareString);
|
|
||||||
intent.setType("text/plain");
|
|
||||||
|
|
||||||
try {
|
|
||||||
startActivity(Intent.createChooser(intent, getString(R.string.VerifyIdentityActivity_share_safety_number_via)));
|
|
||||||
} catch (ActivityNotFoundException e) {
|
|
||||||
Toast.makeText(getActivity(), R.string.VerifyIdentityActivity_no_app_to_share_to, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setRecipientText(Recipient recipient) {
|
|
||||||
description.setText(Html.fromHtml(String.format(getActivity().getString(R.string.verify_display_fragment__if_you_wish_to_verify_the_security_of_your_end_to_end_encryption_with_s), recipient.getDisplayName(getContext()))));
|
|
||||||
description.setMovementMethod(LinkMovementMethod.getInstance());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setFingerprintViews(Fingerprint fingerprint, boolean animate) {
|
|
||||||
String[] segments = getSegments(fingerprint, codes.length);
|
|
||||||
|
|
||||||
for (int i=0;i<codes.length;i++) {
|
|
||||||
if (animate) setCodeSegment(codes[i], segments[i]);
|
|
||||||
else codes[i].setText(segments[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] qrCodeData = fingerprint.getScannableFingerprint().getSerialized();
|
|
||||||
String qrCodeString = new String(qrCodeData, Charset.forName("ISO-8859-1"));
|
|
||||||
Bitmap qrCodeBitmap = QrCode.create(qrCodeString);
|
|
||||||
|
|
||||||
qrCode.setImageBitmap(qrCodeBitmap);
|
|
||||||
|
|
||||||
if (animate) {
|
|
||||||
ViewUtil.fadeIn(qrCode, 1000);
|
|
||||||
ViewUtil.fadeIn(tapLabel, 1000);
|
|
||||||
} else {
|
|
||||||
qrCode.setVisibility(View.VISIBLE);
|
|
||||||
tapLabel.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setCodeSegment(final TextView codeView, String segment) {
|
|
||||||
ValueAnimator valueAnimator = new ValueAnimator();
|
|
||||||
valueAnimator.setObjectValues(0, Integer.parseInt(segment));
|
|
||||||
|
|
||||||
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
|
|
||||||
@Override
|
|
||||||
public void onAnimationUpdate(ValueAnimator animation) {
|
|
||||||
int value = (int) animation.getAnimatedValue();
|
|
||||||
codeView.setText(String.format(Locale.getDefault(), "%05d", value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
valueAnimator.setEvaluator(new TypeEvaluator<Integer>() {
|
|
||||||
public Integer evaluate(float fraction, Integer startValue, Integer endValue) {
|
|
||||||
return Math.round(startValue + (endValue - startValue) * fraction);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
valueAnimator.setDuration(1000);
|
|
||||||
valueAnimator.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private String[] getSegments(Fingerprint fingerprint, int segmentCount) {
|
|
||||||
String[] segments = new String[segmentCount];
|
|
||||||
String digits = fingerprint.getDisplayableFingerprint().getDisplayText();
|
|
||||||
int partSize = digits.length() / segmentCount;
|
|
||||||
|
|
||||||
for (int i=0;i<segmentCount;i++) {
|
|
||||||
segments[i] = digits.substring(i * partSize, (i * partSize) + partSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Bitmap createVerifiedBitmap(int width, int height, @DrawableRes int id) {
|
|
||||||
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
|
|
||||||
Canvas canvas = new Canvas(bitmap);
|
|
||||||
Bitmap check = BitmapFactory.decodeResource(getResources(), id);
|
|
||||||
float offset = (width - check.getWidth()) / 2;
|
|
||||||
|
|
||||||
canvas.drawBitmap(check, offset, offset, null);
|
|
||||||
|
|
||||||
return bitmap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void animateVerifiedSuccess() {
|
|
||||||
Bitmap qrBitmap = ((BitmapDrawable)qrCode.getDrawable()).getBitmap();
|
|
||||||
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_check_white_48dp);
|
|
||||||
|
|
||||||
qrVerified.setImageBitmap(qrSuccess);
|
|
||||||
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.green_500), PorterDuff.Mode.MULTIPLY);
|
|
||||||
|
|
||||||
animateVerified();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void animateVerifiedFailure() {
|
|
||||||
Bitmap qrBitmap = ((BitmapDrawable)qrCode.getDrawable()).getBitmap();
|
|
||||||
Bitmap qrSuccess = createVerifiedBitmap(qrBitmap.getWidth(), qrBitmap.getHeight(), R.drawable.ic_close_white_48dp);
|
|
||||||
|
|
||||||
qrVerified.setImageBitmap(qrSuccess);
|
|
||||||
qrVerified.getBackground().setColorFilter(getResources().getColor(R.color.red_500), PorterDuff.Mode.MULTIPLY);
|
|
||||||
|
|
||||||
animateVerified();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void animateVerified() {
|
|
||||||
ScaleAnimation scaleAnimation = new ScaleAnimation(0, 1, 0, 1,
|
|
||||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
|
|
||||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
|
|
||||||
scaleAnimation.setInterpolator(new OvershootInterpolator());
|
|
||||||
scaleAnimation.setDuration(800);
|
|
||||||
scaleAnimation.setAnimationListener(new Animation.AnimationListener() {
|
|
||||||
@Override
|
|
||||||
public void onAnimationStart(Animation animation) {}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAnimationEnd(Animation animation) {
|
|
||||||
qrVerified.postDelayed(new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
ScaleAnimation scaleAnimation = new ScaleAnimation(1, 0, 1, 0,
|
|
||||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f,
|
|
||||||
ScaleAnimation.RELATIVE_TO_SELF, 0.5f);
|
|
||||||
|
|
||||||
scaleAnimation.setInterpolator(new AnticipateInterpolator());
|
|
||||||
scaleAnimation.setDuration(500);
|
|
||||||
ViewUtil.animateOut(qrVerified, scaleAnimation, View.GONE);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAnimationRepeat(Animation animation) {}
|
|
||||||
});
|
|
||||||
|
|
||||||
ViewUtil.animateIn(qrVerified, scaleAnimation);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCheckedChanged(CompoundButton buttonView, final boolean isChecked) {
|
|
||||||
final Recipient recipient = this.recipient.get();
|
|
||||||
final RecipientId recipientId = recipient.getId();
|
|
||||||
|
|
||||||
SignalExecutors.BOUNDED.execute(() -> {
|
|
||||||
try (SignalSessionLock.Lock unused = DatabaseSessionLock.INSTANCE.acquire()) {
|
|
||||||
if (isChecked) {
|
|
||||||
Log.i(TAG, "Saving identity: " + recipientId);
|
|
||||||
DatabaseFactory.getIdentityDatabase(getActivity())
|
|
||||||
.saveIdentity(recipientId,
|
|
||||||
remoteIdentity,
|
|
||||||
VerifiedStatus.VERIFIED, false,
|
|
||||||
System.currentTimeMillis(), true);
|
|
||||||
} else {
|
|
||||||
DatabaseFactory.getIdentityDatabase(getActivity())
|
|
||||||
.setVerified(recipientId,
|
|
||||||
remoteIdentity,
|
|
||||||
VerifiedStatus.DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplicationDependencies.getJobManager()
|
|
||||||
.add(new MultiDeviceVerifiedUpdateJob(recipientId,
|
|
||||||
remoteIdentity,
|
|
||||||
isChecked ? VerifiedStatus.VERIFIED
|
|
||||||
: VerifiedStatus.DEFAULT));
|
|
||||||
StorageSyncHelper.scheduleSyncForDataChange();
|
|
||||||
|
|
||||||
IdentityUtil.markIdentityVerified(getActivity(), recipient, isChecked, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class VerifyScanFragment extends Fragment {
|
|
||||||
|
|
||||||
private View container;
|
|
||||||
private CameraView cameraView;
|
|
||||||
private ScanningThread scanningThread;
|
|
||||||
private ScanListener scanListener;
|
|
||||||
|
|
||||||
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup viewGroup, Bundle bundle) {
|
|
||||||
this.container = ViewUtil.inflate(inflater, viewGroup, R.layout.verify_scan_fragment);
|
|
||||||
this.cameraView = container.findViewById(R.id.scanner);
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume() {
|
|
||||||
super.onResume();
|
|
||||||
this.scanningThread = new ScanningThread();
|
|
||||||
this.scanningThread.setScanListener(scanListener);
|
|
||||||
this.scanningThread.setCharacterSet("ISO-8859-1");
|
|
||||||
this.cameraView.onResume();
|
|
||||||
this.cameraView.setPreviewCallback(scanningThread);
|
|
||||||
this.scanningThread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause() {
|
|
||||||
super.onPause();
|
|
||||||
this.cameraView.onPause();
|
|
||||||
this.scanningThread.stopScanning();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onConfigurationChanged(Configuration newConfiguration) {
|
|
||||||
super.onConfigurationChanged(newConfiguration);
|
|
||||||
this.cameraView.onPause();
|
|
||||||
this.cameraView.onResume();
|
|
||||||
this.cameraView.setPreviewCallback(scanningThread);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setScanListener(ScanListener listener) {
|
|
||||||
if (this.scanningThread != null) scanningThread.setScanListener(listener);
|
|
||||||
this.scanListener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -18,27 +18,36 @@
|
|||||||
package org.thoughtcrime.securesms;
|
package org.thoughtcrime.securesms;
|
||||||
|
|
||||||
import android.Manifest;
|
import android.Manifest;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
import android.app.PictureInPictureParams;
|
import android.app.PictureInPictureParams;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.pm.ActivityInfo;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
|
import android.graphics.Rect;
|
||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.util.Rational;
|
import android.util.Rational;
|
||||||
import android.view.Window;
|
import android.view.Window;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
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.lifecycle.ViewModelProviders;
|
import androidx.lifecycle.ViewModelProviders;
|
||||||
|
import androidx.window.DisplayFeature;
|
||||||
|
import androidx.window.FoldingFeature;
|
||||||
|
import androidx.window.WindowLayoutInfo;
|
||||||
|
|
||||||
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.logging.Log;
|
import org.signal.core.util.logging.Log;
|
||||||
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;
|
||||||
@@ -49,6 +58,7 @@ import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeN
|
|||||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
|
||||||
|
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
|
||||||
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
|
||||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
@@ -60,15 +70,20 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
|||||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||||
|
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
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.Util;
|
import org.thoughtcrime.securesms.util.Util;
|
||||||
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.audio.SignalAudioManager;
|
||||||
import org.whispersystems.libsignal.IdentityKey;
|
import org.whispersystems.libsignal.IdentityKey;
|
||||||
import org.whispersystems.libsignal.util.Pair;
|
|
||||||
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.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
|
||||||
|
|
||||||
@@ -87,11 +102,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
private CallParticipantsListUpdatePopupWindow participantUpdateWindow;
|
||||||
private DeviceOrientationMonitor deviceOrientationMonitor;
|
private DeviceOrientationMonitor deviceOrientationMonitor;
|
||||||
|
|
||||||
private FullscreenHelper fullscreenHelper;
|
private FullscreenHelper fullscreenHelper;
|
||||||
private WebRtcCallView callScreen;
|
private WebRtcCallView callScreen;
|
||||||
private TooltipPopup videoTooltip;
|
private TooltipPopup videoTooltip;
|
||||||
private WebRtcCallViewModel viewModel;
|
private WebRtcCallViewModel viewModel;
|
||||||
private boolean enableVideoIfAvailable;
|
private boolean enableVideoIfAvailable;
|
||||||
|
private androidx.window.WindowManager windowManager;
|
||||||
|
private WindowLayoutInfoConsumer windowLayoutInfoConsumer;
|
||||||
|
private ThrottledDebouncer requestNewSizesThrottle;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void attachBaseContext(@NonNull Context newBase) {
|
protected void attachBaseContext(@NonNull Context newBase) {
|
||||||
@@ -99,6 +117,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
super.attachBaseContext(newBase);
|
super.attachBaseContext(newBase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SourceLockedOrientationActivity")
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
Log.i(TAG, "onCreate()");
|
Log.i(TAG, "onCreate()");
|
||||||
@@ -106,6 +125,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
|
||||||
|
if (!isLandscapeEnabled) {
|
||||||
|
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||||
|
}
|
||||||
|
|
||||||
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
requestWindowFeature(Window.FEATURE_NO_TITLE);
|
||||||
setContentView(R.layout.webrtc_call_activity);
|
setContentView(R.layout.webrtc_call_activity);
|
||||||
|
|
||||||
@@ -114,12 +138,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
|
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
|
||||||
|
|
||||||
initializeResources();
|
initializeResources();
|
||||||
initializeViewModel();
|
initializeViewModel(isLandscapeEnabled);
|
||||||
|
|
||||||
processIntent(getIntent());
|
processIntent(getIntent());
|
||||||
|
|
||||||
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
|
enableVideoIfAvailable = getIntent().getBooleanExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE, false);
|
||||||
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
getIntent().removeExtra(EXTRA_ENABLE_VIDEO_IF_AVAILABLE);
|
||||||
|
|
||||||
|
windowManager = new androidx.window.WindowManager(this);
|
||||||
|
windowLayoutInfoConsumer = new WindowLayoutInfoConsumer();
|
||||||
|
|
||||||
|
windowManager.registerLayoutChangeCallback(SignalExecutors.BOUNDED, windowLayoutInfoConsumer);
|
||||||
|
|
||||||
|
requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -164,6 +195,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
|
|
||||||
if (!isInPipMode() || isFinishing()) {
|
if (!isInPipMode() || isFinishing()) {
|
||||||
EventBus.getDefault().unregister(this);
|
EventBus.getDefault().unregister(this);
|
||||||
|
requestNewSizesThrottle.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!viewModel.isCallStarting()) {
|
if (!viewModel.isCallStarting()) {
|
||||||
@@ -177,9 +209,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
windowManager.unregisterLayoutChangeCallback(windowLayoutInfoConsumer);
|
||||||
EventBus.getDefault().unregister(this);
|
EventBus.getDefault().unregister(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingSuperCall")
|
||||||
@Override
|
@Override
|
||||||
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
|
||||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults);
|
||||||
@@ -206,8 +240,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private boolean enterPipModeIfPossible() {
|
private boolean enterPipModeIfPossible() {
|
||||||
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
|
if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) {
|
||||||
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
PictureInPictureParams params = new PictureInPictureParams.Builder()
|
||||||
.setAspectRatio(new Rational(9, 16))
|
.setAspectRatio(new Rational(9, 16))
|
||||||
.build();
|
.build();
|
||||||
enterPictureInPictureMode(params);
|
enterPictureInPictureMode(params);
|
||||||
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
CallParticipantsListDialog.dismiss(getSupportFragmentManager());
|
||||||
|
|
||||||
@@ -222,7 +256,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
|
|
||||||
private void processIntent(@NonNull Intent intent) {
|
private void processIntent(@NonNull Intent intent) {
|
||||||
if (ANSWER_ACTION.equals(intent.getAction())) {
|
if (ANSWER_ACTION.equals(intent.getAction())) {
|
||||||
viewModel.setRecipient(EventBus.getDefault().getStickyEvent(WebRtcViewModel.class).getRecipient());
|
|
||||||
handleAnswerWithAudio();
|
handleAnswerWithAudio();
|
||||||
} else if (DENY_ACTION.equals(intent.getAction())) {
|
} else if (DENY_ACTION.equals(intent.getAction())) {
|
||||||
handleDenyCall();
|
handleDenyCall();
|
||||||
@@ -246,53 +279,46 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeViewModel() {
|
private void initializeViewModel(boolean isLandscapeEnabled) {
|
||||||
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
|
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
|
||||||
getLifecycle().addObserver(deviceOrientationMonitor);
|
getLifecycle().addObserver(deviceOrientationMonitor);
|
||||||
|
|
||||||
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
|
||||||
|
|
||||||
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
|
viewModel = ViewModelProviders.of(this, factory).get(WebRtcCallViewModel.class);
|
||||||
|
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
|
||||||
viewModel.setIsInPipMode(isInPipMode());
|
viewModel.setIsInPipMode(isInPipMode());
|
||||||
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
|
||||||
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls);
|
||||||
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
viewModel.getEvents().observe(this, this::handleViewModelEvent);
|
||||||
viewModel.getCallTime().observe(this, this::handleCallTime);
|
viewModel.getCallTime().observe(this, this::handleCallTime);
|
||||||
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientation(), (s, o) -> new Pair<>(s, o == PORTRAIT_BOTTOM_EDGE))
|
|
||||||
.observe(this, p -> callScreen.updateCallParticipants(p.first(), p.second()));
|
LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(),
|
||||||
|
viewModel.getOrientationAndLandscapeEnabled(),
|
||||||
|
(s, o) -> new CallParticipantsViewState(s, o.first == PORTRAIT_BOTTOM_EDGE, o.second))
|
||||||
|
.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);
|
||||||
viewModel.getGroupMembers().observe(this, unused -> updateGroupMembersForGroupCall());
|
viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall());
|
||||||
|
viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange);
|
||||||
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
|
viewModel.shouldShowSpeakerHint().observe(this, this::updateSpeakerHint);
|
||||||
|
|
||||||
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
|
callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
|
||||||
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
CallParticipantsState state = viewModel.getCallParticipantsState().getValue();
|
||||||
if (state != null) {
|
if (state != null) {
|
||||||
if (state.needsNewRequestSizes()) {
|
if (state.needsNewRequestSizes()) {
|
||||||
ApplicationDependencies.getSignalCallManager().updateRenderedResolutions();
|
requestNewSizesThrottle.publish(() -> ApplicationDependencies.getSignalCallManager().updateRenderedResolutions());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
viewModel.getOrientation().observe(this, orientation -> {
|
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> ApplicationDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
|
||||||
ApplicationDependencies.getSignalCallManager().orientationChanged(orientation.getDegrees());
|
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
|
||||||
|
|
||||||
switch (orientation) {
|
|
||||||
case LANDSCAPE_LEFT_EDGE:
|
|
||||||
callScreen.rotateControls(90);
|
|
||||||
break;
|
|
||||||
case LANDSCAPE_RIGHT_EDGE:
|
|
||||||
callScreen.rotateControls(-90);
|
|
||||||
break;
|
|
||||||
case PORTRAIT_BOTTOM_EDGE:
|
|
||||||
callScreen.rotateControls(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
|
||||||
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
|
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
|
||||||
startCall(((WebRtcCallViewModel.Event.StartCall)event).isVideoCall());
|
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
|
||||||
return;
|
return;
|
||||||
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
|
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
|
||||||
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
|
SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords());
|
||||||
@@ -340,15 +366,15 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleSetAudioHandset() {
|
private void handleSetAudioHandset() {
|
||||||
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(false);
|
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.EARPIECE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSetAudioSpeaker() {
|
private void handleSetAudioSpeaker() {
|
||||||
ApplicationDependencies.getSignalCallManager().setAudioSpeaker(true);
|
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.SPEAKER_PHONE);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSetAudioBluetooth() {
|
private void handleSetAudioBluetooth() {
|
||||||
ApplicationDependencies.getSignalCallManager().setAudioBluetooth(true);
|
ApplicationDependencies.getSignalCallManager().selectAudioDevice(SignalAudioManager.AudioDevice.BLUETOOTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleSetMuteAudio(boolean enabled) {
|
private void handleSetMuteAudio(boolean enabled) {
|
||||||
@@ -376,24 +402,19 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void handleAnswerWithAudio() {
|
private void handleAnswerWithAudio() {
|
||||||
Recipient recipient = viewModel.getRecipient().get();
|
Permissions.with(this)
|
||||||
|
.request(Manifest.permission.RECORD_AUDIO)
|
||||||
|
.ifNecessary()
|
||||||
|
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_give_signal_access_to_your_microphone),
|
||||||
|
R.drawable.ic_mic_solid_24)
|
||||||
|
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
||||||
|
.onAllGranted(() -> {
|
||||||
|
callScreen.setStatus(getString(R.string.RedPhone_answering));
|
||||||
|
|
||||||
if (!recipient.equals(Recipient.UNKNOWN)) {
|
ApplicationDependencies.getSignalCallManager().acceptCall(false);
|
||||||
Permissions.with(this)
|
})
|
||||||
.request(Manifest.permission.RECORD_AUDIO)
|
.onAnyDenied(this::handleDenyCall)
|
||||||
.ifNecessary()
|
.execute();
|
||||||
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
|
|
||||||
R.drawable.ic_mic_solid_24)
|
|
||||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
|
||||||
.onAllGranted(() -> {
|
|
||||||
callScreen.setRecipient(recipient);
|
|
||||||
callScreen.setStatus(getString(R.string.RedPhone_answering));
|
|
||||||
|
|
||||||
ApplicationDependencies.getSignalCallManager().acceptCall(false);
|
|
||||||
})
|
|
||||||
.onAnyDenied(this::handleDenyCall)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleAnswerWithVideo() {
|
private void handleAnswerWithVideo() {
|
||||||
@@ -404,7 +425,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
.request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
|
||||||
.ifNecessary()
|
.ifNecessary()
|
||||||
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
|
.withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)),
|
||||||
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
|
R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted)
|
||||||
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
.withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls))
|
||||||
.onAllGranted(() -> {
|
.onAllGranted(() -> {
|
||||||
callScreen.setRecipient(recipient);
|
callScreen.setRecipient(recipient);
|
||||||
@@ -457,6 +478,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
delayedFinish();
|
delayedFinish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleGlare(@NonNull Recipient recipient) {
|
||||||
|
Log.i(TAG, "handleGlare: " + recipient.getId());
|
||||||
|
|
||||||
|
callScreen.setStatus("");
|
||||||
|
}
|
||||||
|
|
||||||
private void handleCallRinging() {
|
private void handleCallRinging() {
|
||||||
callScreen.setStatus(getString(R.string.RedPhone_ringing));
|
callScreen.setStatus(getString(R.string.RedPhone_ringing));
|
||||||
}
|
}
|
||||||
@@ -488,13 +515,13 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
|
private void handleNoSuchUser(final @NonNull WebRtcViewModel event) {
|
||||||
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
|
if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie
|
||||||
new AlertDialog.Builder(this)
|
new AlertDialog.Builder(this)
|
||||||
.setTitle(R.string.RedPhone_number_not_registered)
|
.setTitle(R.string.RedPhone_number_not_registered)
|
||||||
.setIcon(R.drawable.ic_warning)
|
.setIcon(R.drawable.ic_warning)
|
||||||
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
|
.setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice)
|
||||||
.setCancelable(true)
|
.setCancelable(true)
|
||||||
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
.setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||||
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
.setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL))
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
|
private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) {
|
||||||
@@ -524,6 +551,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
|
ApplicationDependencies.getSignalCallManager().requestUpdateGroupMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void handleGroupMemberCountChange(int count) {
|
||||||
|
boolean canRing = count <= FeatureFlags.maxGroupCallRingSize() && FeatureFlags.groupCallRinging();
|
||||||
|
callScreen.enableRingGroup(canRing);
|
||||||
|
ApplicationDependencies.getSignalCallManager().setRingGroup(canRing);
|
||||||
|
}
|
||||||
|
|
||||||
private void updateSpeakerHint(boolean showSpeakerHint) {
|
private void updateSpeakerHint(boolean showSpeakerHint) {
|
||||||
if (showSpeakerHint) {
|
if (showSpeakerHint) {
|
||||||
callScreen.showSpeakerViewHint();
|
callScreen.showSpeakerViewHint();
|
||||||
@@ -586,20 +619,36 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
callScreen.setRecipient(event.getRecipient());
|
callScreen.setRecipient(event.getRecipient());
|
||||||
|
|
||||||
switch (event.getState()) {
|
switch (event.getState()) {
|
||||||
case CALL_PRE_JOIN: handleCallPreJoin(event); break;
|
case CALL_PRE_JOIN:
|
||||||
case CALL_CONNECTED: handleCallConnected(event); break;
|
handleCallPreJoin(event); break;
|
||||||
case NETWORK_FAILURE: handleServerFailure(); break;
|
case CALL_CONNECTED:
|
||||||
case CALL_RINGING: handleCallRinging(); break;
|
handleCallConnected(event); break;
|
||||||
case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
case NETWORK_FAILURE:
|
||||||
case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
handleServerFailure(); break;
|
||||||
case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
case CALL_RINGING:
|
||||||
case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
handleCallRinging(); break;
|
||||||
case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
|
case CALL_DISCONNECTED:
|
||||||
case NO_SUCH_USER: handleNoSuchUser(event); break;
|
handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break;
|
||||||
case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break;
|
case CALL_DISCONNECTED_GLARE:
|
||||||
case CALL_OUTGOING: handleOutgoingCall(event); break;
|
handleGlare(event.getRecipient()); break;
|
||||||
case CALL_BUSY: handleCallBusy(); break;
|
case CALL_ACCEPTED_ELSEWHERE:
|
||||||
case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break;
|
handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break;
|
||||||
|
case CALL_DECLINED_ELSEWHERE:
|
||||||
|
handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break;
|
||||||
|
case CALL_ONGOING_ELSEWHERE:
|
||||||
|
handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break;
|
||||||
|
case CALL_NEEDS_PERMISSION:
|
||||||
|
handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break;
|
||||||
|
case NO_SUCH_USER:
|
||||||
|
handleNoSuchUser(event); break;
|
||||||
|
case RECIPIENT_UNAVAILABLE:
|
||||||
|
handleRecipientUnavailable(); break;
|
||||||
|
case CALL_OUTGOING:
|
||||||
|
handleOutgoingCall(event); break;
|
||||||
|
case CALL_BUSY:
|
||||||
|
handleCallBusy(); break;
|
||||||
|
case UNTRUSTED_IDENTITY:
|
||||||
|
handleUntrustedIdentity(event); break;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
|
boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable;
|
||||||
@@ -615,6 +664,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
private void handleCallPreJoin(@NonNull WebRtcViewModel event) {
|
||||||
if (event.getGroupState().isNotIdle()) {
|
if (event.getGroupState().isNotIdle()) {
|
||||||
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
callScreen.setStatusFromGroupCallState(event.getGroupState());
|
||||||
|
callScreen.setRingGroup(event.shouldRingGroup());
|
||||||
|
|
||||||
|
if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
|
||||||
|
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -729,5 +783,38 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
|||||||
public void onLocalPictureInPictureClicked() {
|
public void onLocalPictureInPictureClicked() {
|
||||||
viewModel.onLocalPictureInPictureClicked();
|
viewModel.onLocalPictureInPictureClicked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) {
|
||||||
|
if (ringingAllowed) {
|
||||||
|
ApplicationDependencies.getSignalCallManager().setRingGroup(ringGroup);
|
||||||
|
} else {
|
||||||
|
ApplicationDependencies.getSignalCallManager().setRingGroup(false);
|
||||||
|
Toast.makeText(WebRtcCallActivity.this, R.string.WebRtcCallActivity__group_is_too_large_to_ring_the_participants, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void accept(WindowLayoutInfo windowLayoutInfo) {
|
||||||
|
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
|
||||||
|
|
||||||
|
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
|
||||||
|
viewModel.setIsLandscapeEnabled(feature.isPresent());
|
||||||
|
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||||
|
if (feature.isPresent()) {
|
||||||
|
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
|
||||||
|
Rect bounds = foldingFeature.getBounds();
|
||||||
|
if (foldingFeature.getState() == FoldingFeature.State.HALF_OPENED && bounds.top == bounds.bottom) {
|
||||||
|
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode");
|
||||||
|
viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top));
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in flat display mode");
|
||||||
|
viewModel.setFoldableState(WebRtcControls.FoldableState.flat());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package org.thoughtcrime.securesms.animation.transitions
|
||||||
|
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.animation.PropertyValuesHolder
|
||||||
|
import android.animation.TypeEvaluator
|
||||||
|
import android.content.Context
|
||||||
|
import android.transition.Transition
|
||||||
|
import android.transition.TransitionValues
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.Interpolator
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
|
||||||
|
private const val POSITION_ON_SCREEN = "signal.circleavatartransition.positiononscreen"
|
||||||
|
private const val WIDTH = "signal.circleavatartransition.width"
|
||||||
|
private const val HEIGHT = "signal.circleavatartransition.height"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom transition for Circular avatars, because once you have multiple things animating stuff was getting broken and weird.
|
||||||
|
*/
|
||||||
|
@RequiresApi(21)
|
||||||
|
class CircleAvatarTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||||
|
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||||
|
captureValues(transitionValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||||
|
captureValues(transitionValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureValues(transitionValues: TransitionValues) {
|
||||||
|
val view: View = transitionValues.view
|
||||||
|
|
||||||
|
if (view.transitionName == "avatar") {
|
||||||
|
val topLeft = intArrayOf(0, 0)
|
||||||
|
view.getLocationOnScreen(topLeft)
|
||||||
|
transitionValues.values[POSITION_ON_SCREEN] = topLeft
|
||||||
|
transitionValues.values[WIDTH] = view.measuredWidth
|
||||||
|
transitionValues.values[HEIGHT] = view.measuredHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||||
|
if (startValues == null || endValues == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val view: View = endValues.view
|
||||||
|
if (view.transitionName != "avatar") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val startCoords: IntArray = startValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
|
||||||
|
val endCoords: IntArray = endValues.values[POSITION_ON_SCREEN] as? IntArray ?: intArrayOf(0, 0).apply { view.getLocationOnScreen(this) }
|
||||||
|
|
||||||
|
val startWidth: Int = startValues.values[WIDTH] as? Int ?: view.measuredWidth
|
||||||
|
val endWidth: Int = endValues.values[WIDTH] as? Int ?: view.measuredWidth
|
||||||
|
|
||||||
|
val startHeight: Int = startValues.values[HEIGHT] as? Int ?: view.measuredHeight
|
||||||
|
val endHeight: Int = endValues.values[HEIGHT] as? Int ?: view.measuredHeight
|
||||||
|
|
||||||
|
val startHeightOffset = (endHeight - startHeight) / 2f
|
||||||
|
val startWidthOffset = (endWidth - startWidth) / 2f
|
||||||
|
|
||||||
|
val translateXHolder = PropertyValuesHolder.ofFloat("translationX", startCoords[0] - endCoords[0] - startWidthOffset, 0f).apply {
|
||||||
|
setEvaluator(FloatInterpolatorEvaluator(DecelerateInterpolator()))
|
||||||
|
}
|
||||||
|
val translateYHolder = PropertyValuesHolder.ofFloat("translationY", startCoords[1] - endCoords[1] - startHeightOffset, 0f).apply {
|
||||||
|
setEvaluator(FloatInterpolatorEvaluator(AccelerateInterpolator()))
|
||||||
|
}
|
||||||
|
|
||||||
|
val widthRatio = startWidth.toFloat() / endWidth
|
||||||
|
val scaleXHolder = PropertyValuesHolder.ofFloat("scaleX", widthRatio, 1f)
|
||||||
|
|
||||||
|
val heightRatio = startHeight.toFloat() / endHeight
|
||||||
|
val scaleYHolder = PropertyValuesHolder.ofFloat("scaleY", heightRatio, 1f)
|
||||||
|
|
||||||
|
return ObjectAnimator.ofPropertyValuesHolder(view, translateXHolder, translateYHolder, scaleXHolder, scaleYHolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FloatInterpolatorEvaluator(
|
||||||
|
private val interpolator: Interpolator
|
||||||
|
) : TypeEvaluator<Float> {
|
||||||
|
|
||||||
|
override fun evaluate(fraction: Float, startValue: Float, endValue: Float): Float {
|
||||||
|
val interpolatedFraction = interpolator.getInterpolation(fraction)
|
||||||
|
val delta = endValue - startValue
|
||||||
|
|
||||||
|
return delta * interpolatedFraction + startValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.thoughtcrime.securesms.animation.transitions
|
||||||
|
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.animation.RectEvaluator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.transition.Transition
|
||||||
|
import android.transition.TransitionValues
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.animation.addListener
|
||||||
|
import androidx.fragment.app.FragmentContainerView
|
||||||
|
|
||||||
|
private const val BOUNDS = "signal.wipedowntransition.bottom"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WipeDownTransition will animate the bottom position of a view such that it "wipes" down the screen to a final position.
|
||||||
|
*/
|
||||||
|
@RequiresApi(21)
|
||||||
|
class WipeDownTransition(context: Context, attrs: AttributeSet?) : Transition(context, attrs) {
|
||||||
|
override fun captureStartValues(transitionValues: TransitionValues) {
|
||||||
|
captureValues(transitionValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun captureEndValues(transitionValues: TransitionValues) {
|
||||||
|
captureValues(transitionValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureValues(transitionValues: TransitionValues) {
|
||||||
|
val view: View = transitionValues.view
|
||||||
|
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
val rect = Rect()
|
||||||
|
view.getLocalVisibleRect(rect)
|
||||||
|
transitionValues.values[BOUNDS] = rect
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createAnimator(sceneRoot: ViewGroup, startValues: TransitionValues?, endValues: TransitionValues?): Animator? {
|
||||||
|
if (startValues == null || endValues == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val view: View = endValues.view
|
||||||
|
if (view !is FragmentContainerView) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val startBottom: Rect = startValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||||
|
val endBottom: Rect = endValues.values[BOUNDS] as? Rect ?: Rect().apply { view.getLocalVisibleRect(this) }
|
||||||
|
|
||||||
|
return ObjectAnimator.ofObject(view, "clipBounds", RectEvaluator(), startBottom, endBottom).apply {
|
||||||
|
addListener(
|
||||||
|
onEnd = {
|
||||||
|
view.clipBounds = null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package org.thoughtcrime.securesms.audio;
|
package org.thoughtcrime.securesms.audio;
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
|
||||||
import android.media.AudioFormat;
|
import android.media.AudioFormat;
|
||||||
import android.media.AudioRecord;
|
import android.media.AudioRecord;
|
||||||
import android.media.MediaCodec;
|
import android.media.MediaCodec;
|
||||||
import android.media.MediaCodecInfo;
|
import android.media.MediaCodecInfo;
|
||||||
import android.media.MediaFormat;
|
import android.media.MediaFormat;
|
||||||
import android.media.MediaRecorder;
|
import android.media.MediaRecorder;
|
||||||
import android.os.Build;
|
import android.os.ParcelFileDescriptor;
|
||||||
|
|
||||||
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;
|
||||||
@@ -17,8 +16,7 @@ import java.io.IOException;
|
|||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
public class AudioCodec implements Recorder {
|
||||||
public class AudioCodec {
|
|
||||||
|
|
||||||
private static final String TAG = Log.tag(AudioCodec.class);
|
private static final String TAG = Log.tag(AudioCodec.class);
|
||||||
|
|
||||||
@@ -32,6 +30,7 @@ public class AudioCodec {
|
|||||||
private final AudioRecord audioRecord;
|
private final AudioRecord audioRecord;
|
||||||
|
|
||||||
private boolean running = true;
|
private boolean running = true;
|
||||||
|
private boolean failed = false;
|
||||||
private boolean finished = false;
|
private boolean finished = false;
|
||||||
|
|
||||||
public AudioCodec() throws IOException {
|
public AudioCodec() throws IOException {
|
||||||
@@ -50,12 +49,19 @@ public class AudioCodec {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(ParcelFileDescriptor fileDescriptor) {
|
||||||
|
Log.i(TAG, "Recording voice note using AudioCodec.");
|
||||||
|
start(new ParcelFileDescriptor.AutoCloseOutputStream(fileDescriptor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public synchronized void stop() {
|
public synchronized void stop() {
|
||||||
running = false;
|
running = false;
|
||||||
while (!finished) Util.wait(this, 0);
|
while (!finished) Util.wait(this, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void start(final OutputStream outputStream) {
|
private void start(final OutputStream outputStream) {
|
||||||
new Thread(new Runnable() {
|
new Thread(new Runnable() {
|
||||||
@Override
|
@Override
|
||||||
public void run() {
|
public void run() {
|
||||||
@@ -76,10 +82,25 @@ public class AudioCodec {
|
|||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
} finally {
|
} finally {
|
||||||
mediaCodec.stop();
|
|
||||||
audioRecord.stop();
|
|
||||||
|
|
||||||
mediaCodec.release();
|
try {
|
||||||
|
mediaCodec.stop();
|
||||||
|
} catch (IllegalStateException ise) {
|
||||||
|
Log.w(TAG, "mediaCodec stop failed.", ise);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
audioRecord.stop();
|
||||||
|
} catch (IllegalStateException ise) {
|
||||||
|
Log.w(TAG, "audioRecord stop failed.", ise);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
mediaCodec.release();
|
||||||
|
} catch (IllegalStateException ise) {
|
||||||
|
Log.w(TAG, "mediaCodec release failed. Probably already released.", ise);
|
||||||
|
}
|
||||||
|
|
||||||
audioRecord.release();
|
audioRecord.release();
|
||||||
|
|
||||||
StreamUtil.close(outputStream);
|
StreamUtil.close(outputStream);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package org.thoughtcrime.securesms.audio;
|
package org.thoughtcrime.securesms.audio;
|
||||||
|
|
||||||
import android.annotation.TargetApi;
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
@@ -11,16 +10,16 @@ import androidx.annotation.NonNull;
|
|||||||
import org.signal.core.util.ThreadUtil;
|
import org.signal.core.util.ThreadUtil;
|
||||||
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.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;
|
||||||
import org.whispersystems.libsignal.util.Pair;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
|
|
||||||
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
|
|
||||||
public class AudioRecorder {
|
public class AudioRecorder {
|
||||||
|
|
||||||
private static final String TAG = Log.tag(AudioRecorder.class);
|
private static final String TAG = Log.tag(AudioRecorder.class);
|
||||||
@@ -29,8 +28,8 @@ public class AudioRecorder {
|
|||||||
|
|
||||||
private final Context context;
|
private final Context context;
|
||||||
|
|
||||||
private AudioCodec audioCodec;
|
private Recorder recorder;
|
||||||
private Uri captureUri;
|
private Uri captureUri;
|
||||||
|
|
||||||
public AudioRecorder(@NonNull Context context) {
|
public AudioRecorder(@NonNull Context context) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -42,7 +41,7 @@ public class AudioRecorder {
|
|||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
|
Log.i(TAG, "Running startRecording() + " + Thread.currentThread().getId());
|
||||||
try {
|
try {
|
||||||
if (audioCodec != null) {
|
if (recorder != null) {
|
||||||
throw new AssertionError("We can only record once at a time.");
|
throw new AssertionError("We can only record once at a time.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,38 +50,38 @@ public class AudioRecorder {
|
|||||||
captureUri = BlobProvider.getInstance()
|
captureUri = BlobProvider.getInstance()
|
||||||
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
.forData(new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), 0)
|
||||||
.withMimeType(MediaUtil.AUDIO_AAC)
|
.withMimeType(MediaUtil.AUDIO_AAC)
|
||||||
.createForSingleSessionOnDiskAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
.createForDraftAttachmentAsync(context, () -> Log.i(TAG, "Write successful."), e -> Log.w(TAG, "Error during recording", e));
|
||||||
audioCodec = new AudioCodec();
|
|
||||||
|
|
||||||
audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1]));
|
recorder = Build.VERSION.SDK_INT >= 26 && FeatureFlags.voiceNoteRecordingV2() ? new MediaRecorderWrapper() : new AudioCodec();
|
||||||
|
recorder.start(fds[1]);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
Log.w(TAG, e);
|
Log.w(TAG, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public @NonNull ListenableFuture<Pair<Uri, Long>> stopRecording() {
|
public @NonNull ListenableFuture<VoiceNoteDraft> stopRecording() {
|
||||||
Log.i(TAG, "stopRecording()");
|
Log.i(TAG, "stopRecording()");
|
||||||
|
|
||||||
final SettableFuture<Pair<Uri, Long>> future = new SettableFuture<>();
|
final SettableFuture<VoiceNoteDraft> future = new SettableFuture<>();
|
||||||
|
|
||||||
executor.execute(() -> {
|
executor.execute(() -> {
|
||||||
if (audioCodec == null) {
|
if (recorder == null) {
|
||||||
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
|
sendToFuture(future, new IOException("MediaRecorder was never initialized successfully!"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
audioCodec.stop();
|
recorder.stop();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
long size = MediaUtil.getMediaSize(context, captureUri);
|
long size = MediaUtil.getMediaSize(context, captureUri);
|
||||||
sendToFuture(future, new Pair<>(captureUri, size));
|
sendToFuture(future, new VoiceNoteDraft(captureUri, size));
|
||||||
} catch (IOException ioe) {
|
} catch (IOException ioe) {
|
||||||
Log.w(TAG, ioe);
|
Log.w(TAG, ioe);
|
||||||
sendToFuture(future, ioe);
|
sendToFuture(future, ioe);
|
||||||
}
|
}
|
||||||
|
|
||||||
audioCodec = null;
|
recorder = null;
|
||||||
captureUri = null;
|
captureUri = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import org.signal.core.util.logging.Log;
|
|||||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||||
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
import org.thoughtcrime.securesms.database.AttachmentDatabase;
|
||||||
import org.thoughtcrime.securesms.database.DatabaseFactory;
|
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||||
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
import org.thoughtcrime.securesms.database.model.databaseprotos.AudioWaveFormData;
|
||||||
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
import org.thoughtcrime.securesms.media.DecryptableUriMediaInput;
|
||||||
import org.thoughtcrime.securesms.media.MediaInput;
|
import org.thoughtcrime.securesms.media.MediaInput;
|
||||||
@@ -65,12 +65,6 @@ public final class AudioWaveForm {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(attachment instanceof DatabaseAttachment)) {
|
|
||||||
Log.i(TAG, "Not yet in database");
|
|
||||||
ThreadUtil.runOnMain(onFailure);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String cacheKey = uri.toString();
|
String cacheKey = uri.toString();
|
||||||
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
AudioFileInfo cached = WAVE_FORM_CACHE.get(cacheKey);
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
@@ -104,26 +98,46 @@ public final class AudioWaveForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (attachment instanceof DatabaseAttachment) {
|
||||||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
try {
|
||||||
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
AttachmentDatabase attachmentDatabase = SignalDatabase.attachments();
|
||||||
long startTime = System.currentTimeMillis();
|
DatabaseAttachment dbAttachment = (DatabaseAttachment) attachment;
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), AudioWaveFormData.getDefaultInstance());
|
||||||
|
|
||||||
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||||
|
|
||||||
AudioFileInfo fileInfo = generateWaveForm(uri);
|
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||||
|
|
||||||
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||||
|
|
||||||
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
attachmentDatabase.writeAudioHash(dbAttachment.getAttachmentId(), fileInfo.toDatabaseProtobuf());
|
||||||
|
|
||||||
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||||
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||||
ThreadUtil.runOnMain(onFailure);
|
ThreadUtil.runOnMain(onFailure);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
Log.i(TAG, "Not in database and not cached. Generating wave form on-the-fly.");
|
||||||
|
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Log.i(TAG, String.format("Starting wave form generation (%s)", cacheKey));
|
||||||
|
|
||||||
|
AudioFileInfo fileInfo = generateWaveForm(uri);
|
||||||
|
|
||||||
|
Log.i(TAG, String.format(Locale.US, "Audio wave form generation time %d ms (%s)", System.currentTimeMillis() - startTime, cacheKey));
|
||||||
|
|
||||||
|
WAVE_FORM_CACHE.put(cacheKey, fileInfo);
|
||||||
|
ThreadUtil.runOnMain(() -> onSuccess.accept(fileInfo));
|
||||||
|
} catch (IOException e) {
|
||||||
|
Log.w(TAG, "Failed to create audio wave form for " + cacheKey, e);
|
||||||
|
ThreadUtil.runOnMain(onFailure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package org.thoughtcrime.securesms.audio;
|
||||||
|
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
|
||||||
|
import org.signal.core.util.logging.Log;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap Android's {@link MediaRecorder} for use with voice notes.
|
||||||
|
*/
|
||||||
|
public class MediaRecorderWrapper implements Recorder {
|
||||||
|
|
||||||
|
private static final String TAG = Log.tag(MediaRecorderWrapper.class);
|
||||||
|
|
||||||
|
private static final int SAMPLE_RATE = 44100;
|
||||||
|
private static final int CHANNELS = 1;
|
||||||
|
private static final int BIT_RATE = 32000;
|
||||||
|
|
||||||
|
private MediaRecorder recorder = null;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(ParcelFileDescriptor fileDescriptor) throws IOException {
|
||||||
|
Log.i(TAG, "Recording voice note using MediaRecorderWrapper.");
|
||||||
|
recorder = new MediaRecorder();
|
||||||
|
recorder.setAudioSource(MediaRecorder.AudioSource.MIC);
|
||||||
|
recorder.setOutputFormat(MediaRecorder.OutputFormat.AAC_ADTS);
|
||||||
|
recorder.setOutputFile(fileDescriptor.getFileDescriptor());
|
||||||
|
recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC);
|
||||||
|
recorder.setAudioSamplingRate(SAMPLE_RATE);
|
||||||
|
recorder.setAudioEncodingBitRate(BIT_RATE);
|
||||||
|
recorder.setAudioChannels(CHANNELS);
|
||||||
|
recorder.prepare();
|
||||||
|
recorder.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void stop() {
|
||||||
|
try {
|
||||||
|
recorder.stop();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
if (e.getClass() != RuntimeException.class) {
|
||||||
|
throw e;
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Recording stopped with no data captured.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
recorder.release();
|
||||||
|
recorder = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.thoughtcrime.securesms.audio;
|
||||||
|
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple abstraction of the interface for the original voice note recording and the new.
|
||||||
|
*/
|
||||||
|
public interface Recorder {
|
||||||
|
void start(ParcelFileDescriptor fileDescriptor) throws IOException;
|
||||||
|
void stop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an Avatar which the user can choose, edit, and render into a bitmap via the renderer.
|
||||||
|
*/
|
||||||
|
sealed class Avatar(
|
||||||
|
open val databaseId: DatabaseId
|
||||||
|
) {
|
||||||
|
data class Resource(
|
||||||
|
val resourceId: Int,
|
||||||
|
val color: Avatars.ColorPair
|
||||||
|
) : Avatar(DatabaseId.DoNotPersist) {
|
||||||
|
override fun isSameAs(other: Avatar): Boolean {
|
||||||
|
return other is Resource && other.resourceId == resourceId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Text(
|
||||||
|
val text: String,
|
||||||
|
val color: Avatars.ColorPair,
|
||||||
|
override val databaseId: DatabaseId,
|
||||||
|
) : Avatar(databaseId) {
|
||||||
|
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||||
|
return copy(databaseId = databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSameAs(other: Avatar): Boolean {
|
||||||
|
return other is Text && other.databaseId == databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Vector(
|
||||||
|
val key: String,
|
||||||
|
val color: Avatars.ColorPair,
|
||||||
|
override val databaseId: DatabaseId,
|
||||||
|
) : Avatar(databaseId) {
|
||||||
|
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||||
|
return copy(databaseId = databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSameAs(other: Avatar): Boolean {
|
||||||
|
return other is Vector && other.key == key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Photo(
|
||||||
|
val uri: Uri,
|
||||||
|
val size: Long,
|
||||||
|
override val databaseId: DatabaseId
|
||||||
|
) : Avatar(databaseId) {
|
||||||
|
override fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||||
|
return copy(databaseId = databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSameAs(other: Avatar): Boolean {
|
||||||
|
return other is Photo && databaseId == other.databaseId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun withDatabaseId(databaseId: DatabaseId): Avatar {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract fun isSameAs(other: Avatar): Boolean
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getDefaultForSelf(): Resource = Resource(R.drawable.ic_profile_outline_40, Avatars.colors.random())
|
||||||
|
fun getDefaultForGroup(): Resource = Resource(R.drawable.ic_group_outline_40, Avatars.colors.random())
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class DatabaseId {
|
||||||
|
object DoNotPersist : DatabaseId()
|
||||||
|
object NotSet : DatabaseId()
|
||||||
|
data class Saved(val id: Long) : DatabaseId()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class which encapsulates reading and writing Avatar objects to and from Bundles.
|
||||||
|
*/
|
||||||
|
object AvatarBundler {
|
||||||
|
|
||||||
|
private const val TEXT = "org.thoughtcrime.securesms.avatar.TEXT"
|
||||||
|
private const val COLOR = "org.thoughtcrime.securesms.avatar.COLOR"
|
||||||
|
private const val URI = "org.thoughtcrime.securesms.avatar.URI"
|
||||||
|
private const val KEY = "org.thoughtcrime.securesms.avatar.KEY"
|
||||||
|
private const val DATABASE_ID = "org.thoughtcrime.securesms.avatar.DATABASE_ID"
|
||||||
|
private const val SIZE = "org.thoughtcrime.securesms.avatar.SIZE"
|
||||||
|
|
||||||
|
fun bundleText(text: Avatar.Text): Bundle = Bundle().apply {
|
||||||
|
putString(TEXT, text.text)
|
||||||
|
putString(COLOR, text.color.code)
|
||||||
|
putDatabaseId(DATABASE_ID, text.databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractText(bundle: Bundle): Avatar.Text = Avatar.Text(
|
||||||
|
text = requireNotNull(bundle.getString(TEXT)),
|
||||||
|
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
|
||||||
|
databaseId = bundle.getDatabaseId()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun bundlePhoto(photo: Avatar.Photo): Bundle = Bundle().apply {
|
||||||
|
putParcelable(URI, photo.uri)
|
||||||
|
putLong(SIZE, photo.size)
|
||||||
|
putDatabaseId(DATABASE_ID, photo.databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractPhoto(bundle: Bundle): Avatar.Photo = Avatar.Photo(
|
||||||
|
uri = requireNotNull(bundle.getParcelable(URI)),
|
||||||
|
size = bundle.getLong(SIZE),
|
||||||
|
databaseId = bundle.getDatabaseId()
|
||||||
|
)
|
||||||
|
|
||||||
|
fun bundleVector(vector: Avatar.Vector): Bundle = Bundle().apply {
|
||||||
|
putString(KEY, vector.key)
|
||||||
|
putString(COLOR, vector.color.code)
|
||||||
|
putDatabaseId(DATABASE_ID, vector.databaseId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extractVector(bundle: Bundle): Avatar.Vector = Avatar.Vector(
|
||||||
|
key = requireNotNull(bundle.getString(KEY)),
|
||||||
|
color = Avatars.colorMap[bundle.getString(COLOR)] ?: throw IllegalStateException(),
|
||||||
|
databaseId = bundle.getDatabaseId()
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Bundle.getDatabaseId(): Avatar.DatabaseId {
|
||||||
|
val id = getLong(DATABASE_ID, -1L)
|
||||||
|
|
||||||
|
return if (id == -1L) {
|
||||||
|
Avatar.DatabaseId.NotSet
|
||||||
|
} else {
|
||||||
|
Avatar.DatabaseId.Saved(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Bundle.putDatabaseId(key: String, databaseId: Avatar.DatabaseId) {
|
||||||
|
if (databaseId is Avatar.DatabaseId.Saved) {
|
||||||
|
putLong(key, databaseId.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import com.airbnb.lottie.SimpleColorFilter
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
|
||||||
|
typealias OnAvatarColorClickListener = (Avatars.ColorPair) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selectable color item for choosing colors when editing a Text or Vector avatar.
|
||||||
|
*/
|
||||||
|
data class AvatarColorItem(
|
||||||
|
val colors: Avatars.ColorPair,
|
||||||
|
val selected: Boolean
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun registerViewHolder(adapter: MappingAdapter, onAvatarColorClickListener: OnAvatarColorClickListener) {
|
||||||
|
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarColorClickListener) }, R.layout.avatar_color_item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Model(val colorItem: AvatarColorItem) : MappingModel<Model> {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean = newItem.colorItem.colors == colorItem.colors
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean = newItem.colorItem == colorItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ViewHolder(itemView: View, private val onAvatarColorClickListener: OnAvatarColorClickListener) : MappingViewHolder<Model>(itemView) {
|
||||||
|
|
||||||
|
private val imageView: ImageView = findViewById(R.id.avatar_color_item)
|
||||||
|
|
||||||
|
override fun bind(model: Model) {
|
||||||
|
itemView.setOnClickListener { onAvatarColorClickListener(model.colorItem.colors) }
|
||||||
|
imageView.background.colorFilter = SimpleColorFilter(model.colorItem.colors.backgroundColor)
|
||||||
|
imageView.isSelected = model.colorItem.selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.MimeTypeMap
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.mediasend.Media
|
||||||
|
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||||
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
|
import org.thoughtcrime.securesms.util.storage.FileStorage
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
object AvatarPickerStorage {
|
||||||
|
|
||||||
|
private const val DIRECTORY = "avatar_picker"
|
||||||
|
private const val FILENAME_BASE = "avatar"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun read(context: Context, fileName: String) = FileStorage.read(context, DIRECTORY, fileName)
|
||||||
|
|
||||||
|
fun save(context: Context, media: Media): Uri {
|
||||||
|
val fileName = FileStorage.save(context, PartAuthority.getAttachmentStream(context, media.uri), DIRECTORY, FILENAME_BASE, MediaUtil.getExtension(context, media.uri) ?: "")
|
||||||
|
|
||||||
|
return PartAuthority.getAvatarPickerUri(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, inputStream: InputStream): Uri {
|
||||||
|
val fileName = FileStorage.save(context, inputStream, DIRECTORY, FILENAME_BASE, MimeTypeMap.getSingleton().getExtensionFromMimeType(MediaUtil.IMAGE_JPEG) ?: "")
|
||||||
|
|
||||||
|
return PartAuthority.getAvatarPickerUri(fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun cleanOrphans(context: Context) {
|
||||||
|
val avatarFiles = FileStorage.getAllFiles(context, DIRECTORY, FILENAME_BASE)
|
||||||
|
val database = SignalDatabase.avatarPicker
|
||||||
|
val photoAvatars = database
|
||||||
|
.getAllAvatars()
|
||||||
|
.filterIsInstance<Avatar.Photo>()
|
||||||
|
|
||||||
|
val inDatabaseFileNames = photoAvatars.map { PartAuthority.getAvatarPickerFilename(it.uri) }
|
||||||
|
val onDiskFileNames = avatarFiles.map { it.name }
|
||||||
|
|
||||||
|
val inDatabaseButNotOnDisk = inDatabaseFileNames - onDiskFileNames
|
||||||
|
val onDiskButNotInDatabase = onDiskFileNames - inDatabaseFileNames
|
||||||
|
|
||||||
|
avatarFiles
|
||||||
|
.filter { onDiskButNotInDatabase.contains(it.name) }
|
||||||
|
.forEach { it.delete() }
|
||||||
|
|
||||||
|
photoAvatars
|
||||||
|
.filter { inDatabaseButNotOnDisk.contains(PartAuthority.getAvatarPickerFilename(it.uri)) }
|
||||||
|
.forEach { database.deleteAvatar(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import com.airbnb.lottie.SimpleColorFilter
|
||||||
|
import org.signal.core.util.concurrent.SignalExecutors
|
||||||
|
import org.thoughtcrime.securesms.mediasend.Media
|
||||||
|
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||||
|
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||||
|
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||||
|
import org.thoughtcrime.securesms.util.MediaUtil
|
||||||
|
import org.whispersystems.libsignal.util.guava.Optional
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.annotation.meta.Exhaustive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders Avatar objects into Media objects. This can involve creating a Bitmap, depending on the
|
||||||
|
* type of Avatar passed to `renderAvatar`
|
||||||
|
*/
|
||||||
|
object AvatarRenderer {
|
||||||
|
|
||||||
|
val DIMENSIONS = AvatarHelper.AVATAR_DIMENSIONS
|
||||||
|
|
||||||
|
fun getTypeface(context: Context): Typeface {
|
||||||
|
return Typeface.createFromAsset(context.assets, "fonts/Inter-Medium.otf")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renderAvatar(context: Context, avatar: Avatar, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||||
|
@Exhaustive
|
||||||
|
when (avatar) {
|
||||||
|
is Avatar.Resource -> renderResource(context, avatar, onAvatarRendered, onRenderFailed)
|
||||||
|
is Avatar.Vector -> renderVector(context, avatar, onAvatarRendered, onRenderFailed)
|
||||||
|
is Avatar.Photo -> renderPhoto(context, avatar, onAvatarRendered)
|
||||||
|
is Avatar.Text -> renderText(context, avatar, onAvatarRendered, onRenderFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun createTextDrawable(
|
||||||
|
context: Context,
|
||||||
|
avatar: Avatar.Text,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
size: Int = DIMENSIONS,
|
||||||
|
synchronous: Boolean = false
|
||||||
|
): Drawable {
|
||||||
|
return TextAvatarDrawable(context, avatar, inverted, size, synchronous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderVector(context: Context, avatar: Avatar.Vector, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||||
|
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||||
|
val drawableResourceId = Avatars.getDrawableResource(avatar.key) ?: return@renderInBackground Result.failure(Exception("Drawable resource for key ${avatar.key} does not exist."))
|
||||||
|
val vector: Drawable = requireNotNull(AppCompatResources.getDrawable(context, drawableResourceId))
|
||||||
|
vector.setBounds(0, 0, DIMENSIONS, DIMENSIONS)
|
||||||
|
|
||||||
|
canvas.drawColor(avatar.color.backgroundColor)
|
||||||
|
vector.draw(canvas)
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderText(context: Context, avatar: Avatar.Text, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||||
|
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||||
|
val textDrawable = createTextDrawable(context, avatar, synchronous = true)
|
||||||
|
|
||||||
|
canvas.drawColor(avatar.color.backgroundColor)
|
||||||
|
textDrawable.draw(canvas)
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderPhoto(context: Context, avatar: Avatar.Photo, onAvatarRendered: (Media) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
val blob = BlobProvider.getInstance()
|
||||||
|
.forData(AvatarPickerStorage.read(context, PartAuthority.getAvatarPickerFilename(avatar.uri)), avatar.size)
|
||||||
|
.createForSingleSessionOnDisk(context)
|
||||||
|
|
||||||
|
onAvatarRendered(createMedia(blob, avatar.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderResource(context: Context, avatar: Avatar.Resource, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit) {
|
||||||
|
renderInBackground(context, onAvatarRendered, onRenderFailed) { canvas ->
|
||||||
|
val resource: Drawable = requireNotNull(AppCompatResources.getDrawable(context, avatar.resourceId))
|
||||||
|
resource.colorFilter = SimpleColorFilter(avatar.color.foregroundColor)
|
||||||
|
|
||||||
|
val padding = (DIMENSIONS * 0.2).toInt()
|
||||||
|
resource.setBounds(0 + padding, 0 + padding, DIMENSIONS - padding, DIMENSIONS - padding)
|
||||||
|
|
||||||
|
canvas.drawColor(avatar.color.backgroundColor)
|
||||||
|
resource.draw(canvas)
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderInBackground(context: Context, onAvatarRendered: (Media) -> Unit, onRenderFailed: (Throwable?) -> Unit, drawAvatar: (Canvas) -> Result<Unit>) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
val canvasBitmap = Bitmap.createBitmap(DIMENSIONS, DIMENSIONS, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(canvasBitmap)
|
||||||
|
|
||||||
|
val drawResult = drawAvatar(canvas)
|
||||||
|
if (drawResult.isFailure) {
|
||||||
|
canvasBitmap.recycle()
|
||||||
|
onRenderFailed(drawResult.exceptionOrNull())
|
||||||
|
}
|
||||||
|
|
||||||
|
val outStream = ByteArrayOutputStream()
|
||||||
|
val compressed = canvasBitmap.compress(Bitmap.CompressFormat.JPEG, 80, outStream)
|
||||||
|
canvasBitmap.recycle()
|
||||||
|
|
||||||
|
if (!compressed) {
|
||||||
|
onRenderFailed(IOException("Failed to compress bitmap"))
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = outStream.toByteArray()
|
||||||
|
val inStream = ByteArrayInputStream(bytes)
|
||||||
|
val uri = BlobProvider.getInstance().forData(inStream, bytes.size.toLong()).createForSingleSessionOnDisk(context)
|
||||||
|
|
||||||
|
onAvatarRendered(createMedia(uri, bytes.size.toLong()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Paint
|
||||||
|
import androidx.annotation.ColorInt
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.Px
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
object Avatars {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enum class mirroring AvatarColors codes but utilizing foreground colors for text or icon tinting.
|
||||||
|
*/
|
||||||
|
enum class ForegroundColor(private val code: String, @ColorInt val colorInt: Int) {
|
||||||
|
A100("A100", 0xFF3838F5.toInt()),
|
||||||
|
A110("A110", 0xFF1251D3.toInt()),
|
||||||
|
A120("A120", 0xFF086DA0.toInt()),
|
||||||
|
A130("A130", 0xFF067906.toInt()),
|
||||||
|
A140("A140", 0xFF661AFF.toInt()),
|
||||||
|
A150("A150", 0xFF9F00F0.toInt()),
|
||||||
|
A160("A160", 0xFFB8057C.toInt()),
|
||||||
|
A170("A170", 0xFFBE0404.toInt()),
|
||||||
|
A180("A180", 0xFF836B01.toInt()),
|
||||||
|
A190("A190", 0xFF7D6F40.toInt()),
|
||||||
|
A200("A200", 0xFF4F4F6D.toInt()),
|
||||||
|
A210("A210", 0xFF5C5C5C.toInt());
|
||||||
|
|
||||||
|
fun deserialize(code: String): ForegroundColor {
|
||||||
|
return values().find { it.code == code } ?: throw IllegalArgumentException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun serialize(): String = code
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping which associates color codes to ColorPair objects containing background and foreground colors.
|
||||||
|
*/
|
||||||
|
val colorMap: Map<String, ColorPair> = ForegroundColor.values().map {
|
||||||
|
ColorPair(AvatarColor.deserialize(it.serialize()), it)
|
||||||
|
}.associateBy {
|
||||||
|
it.code
|
||||||
|
}
|
||||||
|
|
||||||
|
val colors: List<ColorPair> = colorMap.values.toList()
|
||||||
|
|
||||||
|
val defaultAvatarsForSelf = linkedMapOf(
|
||||||
|
"avatar_abstract_01" to DefaultAvatar(R.drawable.ic_avatar_abstract_01, "A130"),
|
||||||
|
"avatar_abstract_02" to DefaultAvatar(R.drawable.ic_avatar_abstract_02, "A120"),
|
||||||
|
"avatar_abstract_03" to DefaultAvatar(R.drawable.ic_avatar_abstract_03, "A170"),
|
||||||
|
"avatar_cat" to DefaultAvatar(R.drawable.ic_avatar_cat, "A190"),
|
||||||
|
"avatar_dog" to DefaultAvatar(R.drawable.ic_avatar_dog, "A140"),
|
||||||
|
"avatar_fox" to DefaultAvatar(R.drawable.ic_avatar_fox, "A190"),
|
||||||
|
"avatar_tucan" to DefaultAvatar(R.drawable.ic_avatar_tucan, "A120"),
|
||||||
|
"avatar_sloth" to DefaultAvatar(R.drawable.ic_avatar_sloth, "A160"),
|
||||||
|
"avatar_dinosaur" to DefaultAvatar(R.drawable.ic_avatar_dinosour, "A130"),
|
||||||
|
"avatar_pig" to DefaultAvatar(R.drawable.ic_avatar_pig, "A180"),
|
||||||
|
"avatar_incognito" to DefaultAvatar(R.drawable.ic_avatar_incognito, "A220"),
|
||||||
|
"avatar_ghost" to DefaultAvatar(R.drawable.ic_avatar_ghost, "A100")
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultAvatarsForGroup = linkedMapOf(
|
||||||
|
"avatar_heart" to DefaultAvatar(R.drawable.ic_avatar_heart, "A180"),
|
||||||
|
"avatar_house" to DefaultAvatar(R.drawable.ic_avatar_house, "A120"),
|
||||||
|
"avatar_melon" to DefaultAvatar(R.drawable.ic_avatar_melon, "A110"),
|
||||||
|
"avatar_drink" to DefaultAvatar(R.drawable.ic_avatar_drink, "A170"),
|
||||||
|
"avatar_celebration" to DefaultAvatar(R.drawable.ic_avatar_celebration, "A100"),
|
||||||
|
"avatar_balloon" to DefaultAvatar(R.drawable.ic_avatar_balloon, "A220"),
|
||||||
|
"avatar_book" to DefaultAvatar(R.drawable.ic_avatar_book, "A100"),
|
||||||
|
"avatar_briefcase" to DefaultAvatar(R.drawable.ic_avatar_briefcase, "A180"),
|
||||||
|
"avatar_sunset" to DefaultAvatar(R.drawable.ic_avatar_sunset, "A120"),
|
||||||
|
"avatar_surfboard" to DefaultAvatar(R.drawable.ic_avatar_surfboard, "A110"),
|
||||||
|
"avatar_soccerball" to DefaultAvatar(R.drawable.ic_avatar_soccerball, "A130"),
|
||||||
|
"avatar_football" to DefaultAvatar(R.drawable.ic_avatar_football, "A220"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun getDrawableResource(key: String): Int? {
|
||||||
|
val defaultAvatar = defaultAvatarsForSelf.getOrDefault(key, defaultAvatarsForGroup[key])
|
||||||
|
|
||||||
|
return defaultAvatar?.vectorDrawableId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun textPaint(context: Context) = Paint().apply {
|
||||||
|
isAntiAlias = true
|
||||||
|
typeface = AvatarRenderer.getTypeface(context)
|
||||||
|
textSize = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the text size for a give string using a maximum desired width and a maximum desired font size.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun getTextSizeForLength(context: Context, text: String, @Px maxWidth: Float, @Px maxSize: Float): Float {
|
||||||
|
val paint = textPaint(context)
|
||||||
|
return branchSizes(0f, maxWidth / 2, maxWidth, maxSize, paint, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses binary search to determine optimal font size to within 1% given the input parameters.
|
||||||
|
*/
|
||||||
|
private fun branchSizes(@Px lastFontSize: Float, @Px fontSize: Float, @Px target: Float, @Px maxFontSize: Float, paint: Paint, text: String): Float {
|
||||||
|
paint.textSize = fontSize
|
||||||
|
val textWidth = paint.measureText(text)
|
||||||
|
val delta = abs(lastFontSize - fontSize) / 2f
|
||||||
|
val isWithinThreshold = abs(1f - (textWidth / target)) <= 0.01f
|
||||||
|
|
||||||
|
if (textWidth == 0f) {
|
||||||
|
return maxFontSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta == 0f) {
|
||||||
|
return min(maxFontSize, fontSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when {
|
||||||
|
fontSize >= maxFontSize -> {
|
||||||
|
maxFontSize
|
||||||
|
}
|
||||||
|
isWithinThreshold -> {
|
||||||
|
fontSize
|
||||||
|
}
|
||||||
|
textWidth > target -> {
|
||||||
|
branchSizes(fontSize, fontSize - delta, target, maxFontSize, paint, text)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
branchSizes(fontSize, fontSize + delta, target, maxFontSize, paint, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getForegroundColor(avatarColor: AvatarColor): ForegroundColor {
|
||||||
|
return ForegroundColor.values().firstOrNull { it.serialize() == avatarColor.serialize() } ?: ForegroundColor.A210
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DefaultAvatar(
|
||||||
|
@DrawableRes val vectorDrawableId: Int,
|
||||||
|
val colorCode: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ColorPair(
|
||||||
|
val backgroundAvatarColor: AvatarColor,
|
||||||
|
val foregroundAvatarColor: ForegroundColor
|
||||||
|
) {
|
||||||
|
@ColorInt val backgroundColor: Int = backgroundAvatarColor.colorInt()
|
||||||
|
@ColorInt val foregroundColor: Int = foregroundAvatarColor.colorInt
|
||||||
|
val code: String = backgroundAvatarColor.serialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.ColorFilter
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.SpannableString
|
||||||
|
import android.text.StaticLayout
|
||||||
|
import android.text.TextPaint
|
||||||
|
import androidx.core.graphics.withTranslation
|
||||||
|
import org.thoughtcrime.securesms.components.emoji.EmojiProvider
|
||||||
|
|
||||||
|
class TextAvatarDrawable(
|
||||||
|
private val context: Context,
|
||||||
|
private val avatar: Avatar.Text,
|
||||||
|
inverted: Boolean = false,
|
||||||
|
private val size: Int = AvatarRenderer.DIMENSIONS,
|
||||||
|
private val synchronous: Boolean = false
|
||||||
|
) : Drawable() {
|
||||||
|
|
||||||
|
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
init {
|
||||||
|
textPaint.typeface = AvatarRenderer.getTypeface(context)
|
||||||
|
textPaint.color = if (inverted) avatar.color.backgroundColor else avatar.color.foregroundColor
|
||||||
|
textPaint.density = context.resources.displayMetrics.density
|
||||||
|
|
||||||
|
setBounds(0, 0, size, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun draw(canvas: Canvas) {
|
||||||
|
val textSize = Avatars.getTextSizeForLength(context, avatar.text, size * 0.8f, size * 0.45f)
|
||||||
|
val width = bounds.width()
|
||||||
|
val candidates = EmojiProvider.getCandidates(avatar.text)
|
||||||
|
var hasEmoji = false
|
||||||
|
|
||||||
|
textPaint.textSize = textSize
|
||||||
|
|
||||||
|
val newText = if (candidates == null || candidates.size() == 0) {
|
||||||
|
SpannableString(avatar.text)
|
||||||
|
} else {
|
||||||
|
EmojiProvider.emojify(context, candidates, avatar.text, textPaint, synchronous)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newText == null) return
|
||||||
|
|
||||||
|
val layout = StaticLayout(SpannableString(newText), textPaint, width, Layout.Alignment.ALIGN_NORMAL, 0f, 0f, true)
|
||||||
|
layout.draw(canvas, getStartX(layout), ((bounds.height() / 2) - ((layout.height / 2))).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStartX(layout: StaticLayout): Float {
|
||||||
|
val direction = layout.getParagraphDirection(0)
|
||||||
|
val lineWidth = layout.getLineWidth(0)
|
||||||
|
val width = bounds.width()
|
||||||
|
val xPos = (width - lineWidth) / 2
|
||||||
|
return if (direction == Layout.DIR_LEFT_TO_RIGHT) xPos else -xPos
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAlpha(alpha: Int) = Unit
|
||||||
|
|
||||||
|
override fun setColorFilter(colorFilter: ColorFilter?) = Unit
|
||||||
|
|
||||||
|
override fun getOpacity(): Int = PixelFormat.OPAQUE
|
||||||
|
|
||||||
|
private fun Layout.draw(canvas: Canvas, x: Float, y: Float) {
|
||||||
|
canvas.withTranslation(x, y) {
|
||||||
|
draw(canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.photo
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.commit
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.navigation.Navigation
|
||||||
|
import org.signal.core.util.ThreadUtil
|
||||||
|
import org.signal.core.util.concurrent.SignalExecutors
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||||
|
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||||
|
|
||||||
|
class PhotoEditorFragment : Fragment(R.layout.avatar_photo_editor_fragment), ImageEditorFragment.Controller {
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||||
|
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
|
||||||
|
val imageEditorFragment = ImageEditorFragment.newInstanceForAvatarEdit(photo.uri)
|
||||||
|
|
||||||
|
childFragmentManager.commit {
|
||||||
|
add(R.id.fragment_container, imageEditorFragment, IMAGE_EDITOR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEventsNeeded(needed: Boolean) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestFullScreen(fullScreen: Boolean, hideKeyboard: Boolean) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDoneEditing() {
|
||||||
|
val args = PhotoEditorFragmentArgs.fromBundle(requireArguments())
|
||||||
|
val applicationContext = requireContext().applicationContext
|
||||||
|
val imageEditorFragment: ImageEditorFragment = childFragmentManager.findFragmentByTag(IMAGE_EDITOR) as ImageEditorFragment
|
||||||
|
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
val editedImageUri = imageEditorFragment.renderToSingleUseBlob()
|
||||||
|
val size = BlobProvider.getFileSize(editedImageUri) ?: 0
|
||||||
|
val inputStream = BlobProvider.getInstance().getStream(applicationContext, editedImageUri)
|
||||||
|
val onDiskUri = AvatarPickerStorage.save(applicationContext, inputStream)
|
||||||
|
val photo = AvatarBundler.extractPhoto(args.photoAvatar)
|
||||||
|
val database = SignalDatabase.avatarPicker
|
||||||
|
val newPhoto = photo.copy(uri = onDiskUri, size = size)
|
||||||
|
|
||||||
|
database.update(newPhoto)
|
||||||
|
BlobProvider.getInstance().delete(requireContext(), photo.uri)
|
||||||
|
|
||||||
|
ThreadUtil.runOnMain {
|
||||||
|
setFragmentResult(REQUEST_KEY_EDIT, AvatarBundler.bundlePhoto(newPhoto))
|
||||||
|
Navigation.findNavController(requireView()).popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancelEditing() {
|
||||||
|
Navigation.findNavController(requireView()).popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMainImageLoaded() {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMainImageFailedToLoad() {
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_KEY_EDIT = "org.thoughtcrime.securesms.avatar.photo.EDIT"
|
||||||
|
|
||||||
|
private const val IMAGE_EDITOR = "image_editor"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.picker
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.PopupMenu
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.fragment.app.setFragmentResultListener
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.Navigation
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import org.signal.core.util.ThreadUtil
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||||
|
import org.thoughtcrime.securesms.avatar.photo.PhotoEditorFragment
|
||||||
|
import org.thoughtcrime.securesms.avatar.text.TextAvatarCreationFragment
|
||||||
|
import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment
|
||||||
|
import org.thoughtcrime.securesms.components.ButtonStripItemView
|
||||||
|
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||||
|
import org.thoughtcrime.securesms.groups.ParcelableGroupId
|
||||||
|
import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity
|
||||||
|
import org.thoughtcrime.securesms.mediasend.Media
|
||||||
|
import org.thoughtcrime.securesms.permissions.Permissions
|
||||||
|
import org.thoughtcrime.securesms.util.ViewUtil
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.navigation.safeNavigate
|
||||||
|
import org.thoughtcrime.securesms.util.visible
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary Avatar picker fragment, displays current user avatar and a list of recently used avatars and defaults.
|
||||||
|
*/
|
||||||
|
class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_KEY_SELECT_AVATAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR"
|
||||||
|
const val SELECT_AVATAR_MEDIA = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_MEDIA"
|
||||||
|
const val SELECT_AVATAR_CLEAR = "org.thoughtcrime.securesms.avatar.picker.SELECT_AVATAR_CLEAR"
|
||||||
|
|
||||||
|
private const val REQUEST_CODE_SELECT_IMAGE = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private val viewModel: AvatarPickerViewModel by viewModels(factoryProducer = this::createFactory)
|
||||||
|
|
||||||
|
private lateinit var recycler: RecyclerView
|
||||||
|
|
||||||
|
private fun createFactory(): AvatarPickerViewModel.Factory {
|
||||||
|
val args = AvatarPickerFragmentArgs.fromBundle(requireArguments())
|
||||||
|
val groupId = ParcelableGroupId.get(args.groupId)
|
||||||
|
|
||||||
|
return AvatarPickerViewModel.Factory(AvatarPickerRepository(requireContext()), groupId, args.isNewGroup, args.groupAvatarMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val toolbar: Toolbar = view.findViewById(R.id.avatar_picker_toolbar)
|
||||||
|
val cameraButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_camera)
|
||||||
|
val photoButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_photo)
|
||||||
|
val textButton: ButtonStripItemView = view.findViewById(R.id.avatar_picker_text)
|
||||||
|
val saveButton: View = view.findViewById(R.id.avatar_picker_save)
|
||||||
|
val clearButton: View = view.findViewById(R.id.avatar_picker_clear)
|
||||||
|
|
||||||
|
recycler = view.findViewById(R.id.avatar_picker_recycler)
|
||||||
|
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||||
|
|
||||||
|
val adapter = MappingAdapter()
|
||||||
|
AvatarPickerItem.register(adapter, this::onAvatarClick, this::onAvatarLongClick)
|
||||||
|
|
||||||
|
recycler.adapter = adapter
|
||||||
|
|
||||||
|
val avatarViewHolder = AvatarPickerItem.ViewHolder(view)
|
||||||
|
|
||||||
|
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||||
|
if (state.currentAvatar != null) {
|
||||||
|
avatarViewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
clearButton.visible = state.canClear
|
||||||
|
|
||||||
|
val wasEnabled = saveButton.isEnabled
|
||||||
|
saveButton.isEnabled = state.canSave
|
||||||
|
if (wasEnabled != state.canSave) {
|
||||||
|
val alpha = if (state.canSave) 1f else 0.5f
|
||||||
|
saveButton.animate().cancel()
|
||||||
|
saveButton.animate().alpha(alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
val items = state.selectableAvatars.map { AvatarPickerItem.Model(it, it == state.currentAvatar) }
|
||||||
|
val selectedPosition = items.indexOfFirst { it.isSelected }
|
||||||
|
|
||||||
|
adapter.submitList(items) {
|
||||||
|
if (selectedPosition > -1)
|
||||||
|
recycler.smoothScrollToPosition(selectedPosition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||||
|
cameraButton.setOnIconClickedListener { openCameraCapture() }
|
||||||
|
photoButton.setOnIconClickedListener { openGallery() }
|
||||||
|
textButton.setOnIconClickedListener { openTextEditor(null) }
|
||||||
|
saveButton.setOnClickListener { v ->
|
||||||
|
viewModel.save(
|
||||||
|
{
|
||||||
|
setFragmentResult(
|
||||||
|
REQUEST_KEY_SELECT_AVATAR,
|
||||||
|
Bundle().apply {
|
||||||
|
putParcelable(SELECT_AVATAR_MEDIA, it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setFragmentResult(
|
||||||
|
REQUEST_KEY_SELECT_AVATAR,
|
||||||
|
Bundle().apply {
|
||||||
|
putBoolean(SELECT_AVATAR_CLEAR, true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ThreadUtil.runOnMain { Navigation.findNavController(v).popBackStack() }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearButton.setOnClickListener { viewModel.clear() }
|
||||||
|
|
||||||
|
setFragmentResultListener(TextAvatarCreationFragment.REQUEST_KEY_TEXT) { _, bundle ->
|
||||||
|
val text = AvatarBundler.extractText(bundle)
|
||||||
|
viewModel.onAvatarEditCompleted(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFragmentResultListener(VectorAvatarCreationFragment.REQUEST_KEY_VECTOR) { _, bundle ->
|
||||||
|
val vector = AvatarBundler.extractVector(bundle)
|
||||||
|
viewModel.onAvatarEditCompleted(vector)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFragmentResultListener(PhotoEditorFragment.REQUEST_KEY_EDIT) { _, bundle ->
|
||||||
|
val photo = AvatarBundler.extractPhoto(bundle)
|
||||||
|
viewModel.onAvatarEditCompleted(photo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
if (requestCode == REQUEST_CODE_SELECT_IMAGE && resultCode == Activity.RESULT_OK && data != null) {
|
||||||
|
val media: Media = requireNotNull(data.getParcelableExtra(AvatarSelectionActivity.EXTRA_MEDIA))
|
||||||
|
viewModel.onAvatarPhotoSelectionCompleted(media)
|
||||||
|
} else {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAvatarClick(avatar: Avatar, isSelected: Boolean) {
|
||||||
|
if (isSelected) {
|
||||||
|
openEditor(avatar)
|
||||||
|
} else {
|
||||||
|
viewModel.onAvatarSelectedFromGrid(avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onAvatarLongClick(anchorView: View, avatar: Avatar): Boolean {
|
||||||
|
val menuRes = when (avatar) {
|
||||||
|
is Avatar.Photo -> R.menu.avatar_picker_context
|
||||||
|
is Avatar.Text -> R.menu.avatar_picker_context
|
||||||
|
is Avatar.Vector -> return true
|
||||||
|
is Avatar.Resource -> return true
|
||||||
|
}
|
||||||
|
|
||||||
|
val popup = PopupMenu(context, anchorView, Gravity.TOP)
|
||||||
|
popup.menuInflater.inflate(menuRes, popup.menu)
|
||||||
|
popup.setOnMenuItemClickListener { menuItem ->
|
||||||
|
when (menuItem.itemId) {
|
||||||
|
R.id.action_delete -> viewModel.delete(avatar)
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
popup.show()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openEditor(avatar: Avatar) {
|
||||||
|
when (avatar) {
|
||||||
|
is Avatar.Photo -> openPhotoEditor(avatar)
|
||||||
|
is Avatar.Resource -> throw UnsupportedOperationException()
|
||||||
|
is Avatar.Text -> openTextEditor(avatar)
|
||||||
|
is Avatar.Vector -> openVectorEditor(avatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openPhotoEditor(photo: Avatar.Photo) {
|
||||||
|
Navigation.findNavController(requireView())
|
||||||
|
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToAvatarPhotoEditorFragment(AvatarBundler.bundlePhoto(photo)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openVectorEditor(vector: Avatar.Vector) {
|
||||||
|
Navigation.findNavController(requireView())
|
||||||
|
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToVectorAvatarCreationFragment(AvatarBundler.bundleVector(vector)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openTextEditor(text: Avatar.Text?) {
|
||||||
|
val bundle = if (text != null) AvatarBundler.bundleText(text) else null
|
||||||
|
Navigation.findNavController(requireView())
|
||||||
|
.safeNavigate(AvatarPickerFragmentDirections.actionAvatarPickerFragmentToTextAvatarCreationFragment(bundle))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun openCameraCapture() {
|
||||||
|
Permissions.with(this)
|
||||||
|
.request(Manifest.permission.CAMERA)
|
||||||
|
.ifNecessary()
|
||||||
|
.onAllGranted {
|
||||||
|
val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext())
|
||||||
|
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||||
|
}
|
||||||
|
.onAnyDenied {
|
||||||
|
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__taking_a_photo_requires_the_camera_permission, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun openGallery() {
|
||||||
|
Permissions.with(this)
|
||||||
|
.request(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
.ifNecessary()
|
||||||
|
.onAllGranted {
|
||||||
|
val intent = AvatarSelectionActivity.getIntentForGallery(requireContext())
|
||||||
|
startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE)
|
||||||
|
}
|
||||||
|
.onAnyDenied {
|
||||||
|
Toast.makeText(requireContext(), R.string.AvatarSelectionBottomSheetDialogFragment__viewing_your_gallery_requires_the_storage_permission, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.picker
|
||||||
|
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.core.view.setPadding
|
||||||
|
import com.airbnb.lottie.SimpleColorFilter
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
import org.thoughtcrime.securesms.util.visible
|
||||||
|
|
||||||
|
typealias OnAvatarClickListener = (Avatar, Boolean) -> Unit
|
||||||
|
typealias OnAvatarLongClickListener = (View, Avatar) -> Boolean
|
||||||
|
|
||||||
|
object AvatarPickerItem {
|
||||||
|
|
||||||
|
private val SELECTION_CHANGED = Any()
|
||||||
|
|
||||||
|
fun register(adapter: MappingAdapter, onAvatarClickListener: OnAvatarClickListener, onAvatarLongClickListener: OnAvatarLongClickListener) {
|
||||||
|
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onAvatarClickListener, onAvatarLongClickListener) }, R.layout.avatar_picker_item))
|
||||||
|
}
|
||||||
|
|
||||||
|
class Model(val avatar: Avatar, val isSelected: Boolean) : MappingModel<Model> {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean = avatar.isSameAs(newItem.avatar)
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean = avatar == newItem.avatar && isSelected == newItem.isSelected
|
||||||
|
|
||||||
|
override fun getChangePayload(newItem: Model): Any? {
|
||||||
|
return if (newItem.avatar == avatar && isSelected != newItem.isSelected) {
|
||||||
|
SELECTION_CHANGED
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(
|
||||||
|
itemView: View,
|
||||||
|
private val onAvatarClickListener: OnAvatarClickListener? = null,
|
||||||
|
private val onAvatarLongClickListener: OnAvatarLongClickListener? = null
|
||||||
|
) : MappingViewHolder<Model>(itemView) {
|
||||||
|
|
||||||
|
private val imageView: ImageView = itemView.findViewById(R.id.avatar_picker_item_image)
|
||||||
|
private val textView: TextView = itemView.findViewById(R.id.avatar_picker_item_text)
|
||||||
|
private val selectedFader: View? = itemView.findViewById(R.id.avatar_picker_item_fader)
|
||||||
|
private val selectedOverlay: View? = itemView.findViewById(R.id.avatar_picker_item_selection_overlay)
|
||||||
|
|
||||||
|
init {
|
||||||
|
textView.typeface = AvatarRenderer.getTypeface(context)
|
||||||
|
textView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||||
|
updateFontSize(textView.text.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateFontSize(text: String) {
|
||||||
|
val textSize = Avatars.getTextSizeForLength(context, text, textView.measuredWidth * 0.8f, textView.measuredHeight * 0.45f)
|
||||||
|
textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize)
|
||||||
|
|
||||||
|
if (textView !is EditText) {
|
||||||
|
textView.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(model: Model) {
|
||||||
|
val alpha = if (model.isSelected) 1f else 0f
|
||||||
|
val scale = if (model.isSelected) 0.9f else 1f
|
||||||
|
|
||||||
|
imageView.animate().cancel()
|
||||||
|
textView.animate().cancel()
|
||||||
|
selectedOverlay?.animate()?.cancel()
|
||||||
|
selectedFader?.animate()?.cancel()
|
||||||
|
|
||||||
|
itemView.setOnLongClickListener {
|
||||||
|
onAvatarLongClickListener?.invoke(itemView, model.avatar) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
itemView.setOnClickListener { onAvatarClickListener?.invoke(model.avatar, model.isSelected) }
|
||||||
|
|
||||||
|
if (payload.isNotEmpty() && payload.contains(SELECTION_CHANGED)) {
|
||||||
|
imageView.animate().scaleX(scale).scaleY(scale)
|
||||||
|
textView.animate().scaleX(scale).scaleY(scale)
|
||||||
|
selectedOverlay?.animate()?.alpha(alpha)
|
||||||
|
selectedFader?.animate()?.alpha(alpha)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView.scaleX = scale
|
||||||
|
imageView.scaleY = scale
|
||||||
|
textView.scaleX = scale
|
||||||
|
textView.scaleY = scale
|
||||||
|
selectedFader?.alpha = alpha
|
||||||
|
selectedOverlay?.alpha = alpha
|
||||||
|
|
||||||
|
imageView.clearColorFilter()
|
||||||
|
imageView.setPadding(0)
|
||||||
|
|
||||||
|
when (model.avatar) {
|
||||||
|
is Avatar.Text -> {
|
||||||
|
textView.visible = true
|
||||||
|
|
||||||
|
updateFontSize(model.avatar.text)
|
||||||
|
if (textView.text.toString() != model.avatar.text) {
|
||||||
|
textView.text = model.avatar.text
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView.setImageDrawable(null)
|
||||||
|
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||||
|
textView.setTextColor(model.avatar.color.foregroundColor)
|
||||||
|
}
|
||||||
|
is Avatar.Vector -> {
|
||||||
|
textView.visible = false
|
||||||
|
|
||||||
|
val drawableId = Avatars.getDrawableResource(model.avatar.key)
|
||||||
|
if (drawableId == null) {
|
||||||
|
imageView.setImageDrawable(null)
|
||||||
|
} else {
|
||||||
|
imageView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId))
|
||||||
|
}
|
||||||
|
|
||||||
|
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||||
|
}
|
||||||
|
is Avatar.Photo -> {
|
||||||
|
textView.visible = false
|
||||||
|
GlideApp.with(imageView).load(DecryptableStreamUriLoader.DecryptableUri(model.avatar.uri)).into(imageView)
|
||||||
|
}
|
||||||
|
is Avatar.Resource -> {
|
||||||
|
imageView.setPadding((imageView.width * 0.2).toInt())
|
||||||
|
textView.visible = false
|
||||||
|
GlideApp.with(imageView).clear(imageView)
|
||||||
|
imageView.setImageResource(model.avatar.resourceId)
|
||||||
|
imageView.colorFilter = SimpleColorFilter(model.avatar.color.foregroundColor)
|
||||||
|
imageView.background.colorFilter = SimpleColorFilter(model.avatar.color.backgroundColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.picker
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import org.signal.core.util.StreamUtil
|
||||||
|
import org.signal.core.util.ThreadUtil
|
||||||
|
import org.signal.core.util.concurrent.SignalExecutors
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarRenderer
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
|
import org.thoughtcrime.securesms.mediasend.Media
|
||||||
|
import org.thoughtcrime.securesms.profiles.AvatarHelper
|
||||||
|
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.NameUtil
|
||||||
|
import org.whispersystems.signalservice.api.util.StreamDetails
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
private val TAG = Log.tag(AvatarPickerRepository::class.java)
|
||||||
|
|
||||||
|
class AvatarPickerRepository(context: Context) {
|
||||||
|
|
||||||
|
private val applicationContext = context.applicationContext
|
||||||
|
|
||||||
|
fun getAvatarForSelf(): Single<Avatar> = Single.fromCallable {
|
||||||
|
val details: StreamDetails? = AvatarHelper.getSelfProfileAvatarStream(applicationContext)
|
||||||
|
if (details != null) {
|
||||||
|
try {
|
||||||
|
val bytes = StreamUtil.readFully(details.stream)
|
||||||
|
Avatar.Photo(
|
||||||
|
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
|
||||||
|
details.length,
|
||||||
|
Avatar.DatabaseId.DoNotPersist
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "Failed to read avatar!")
|
||||||
|
getDefaultAvatarForSelf()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getDefaultAvatarForSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAvatarForGroup(groupId: GroupId): Single<Avatar> = Single.fromCallable {
|
||||||
|
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||||
|
|
||||||
|
if (AvatarHelper.hasAvatar(applicationContext, recipient.id)) {
|
||||||
|
try {
|
||||||
|
val bytes = AvatarHelper.getAvatarBytes(applicationContext, recipient.id)
|
||||||
|
Avatar.Photo(
|
||||||
|
BlobProvider.getInstance().forData(bytes).createForSingleSessionInMemory(),
|
||||||
|
AvatarHelper.getAvatarLength(applicationContext, recipient.id),
|
||||||
|
Avatar.DatabaseId.DoNotPersist
|
||||||
|
)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.w(TAG, "Failed to read group avatar!")
|
||||||
|
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
getDefaultAvatarForGroup(recipient.avatarColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPersistedAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||||
|
SignalDatabase.avatarPicker.getAvatarsForSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPersistedAvatarsForGroup(groupId: GroupId): Single<List<Avatar>> = Single.fromCallable {
|
||||||
|
SignalDatabase.avatarPicker.getAvatarsForGroup(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultAvatarsForSelf(): Single<List<Avatar>> = Single.fromCallable {
|
||||||
|
Avatars.defaultAvatarsForSelf.entries.mapIndexed { index, entry ->
|
||||||
|
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultAvatarsForGroup(): Single<List<Avatar>> = Single.fromCallable {
|
||||||
|
Avatars.defaultAvatarsForGroup.entries.mapIndexed { index, entry ->
|
||||||
|
Avatar.Vector(entry.key, color = Avatars.colors[index % Avatars.colors.size], Avatar.DatabaseId.NotSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeMediaToMultiSessionStorage(media: Media, onMediaWrittenToMultiSessionStorage: (Uri) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
onMediaWrittenToMultiSessionStorage(AvatarPickerStorage.save(applicationContext, media))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persistAvatarForSelf(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
val avatarDatabase = SignalDatabase.avatarPicker
|
||||||
|
val savedAvatar = avatarDatabase.saveAvatarForSelf(avatar)
|
||||||
|
avatarDatabase.markUsage(savedAvatar)
|
||||||
|
onPersisted(savedAvatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persistAvatarForGroup(avatar: Avatar, groupId: GroupId, onPersisted: (Avatar) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
val avatarDatabase = SignalDatabase.avatarPicker
|
||||||
|
val savedAvatar = avatarDatabase.saveAvatarForGroup(avatar, groupId)
|
||||||
|
avatarDatabase.markUsage(savedAvatar)
|
||||||
|
onPersisted(savedAvatar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persistAndCreateMediaForSelf(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||||
|
persistAvatarForSelf(avatar) {
|
||||||
|
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun persistAndCreateMediaForGroup(avatar: Avatar, groupId: GroupId, onSaved: (Media) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
if (avatar.databaseId !is Avatar.DatabaseId.DoNotPersist) {
|
||||||
|
persistAvatarForGroup(avatar, groupId) {
|
||||||
|
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createMediaForNewGroup(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
AvatarRenderer.renderAvatar(applicationContext, avatar, onSaved, this::handleRenderFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handleRenderFailure(throwable: Throwable?) {
|
||||||
|
Log.w(TAG, "Failed to render avatar.", throwable)
|
||||||
|
ThreadUtil.postToMain {
|
||||||
|
Toast.makeText(applicationContext, R.string.AvatarPickerRepository__failed_to_save_avatar, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultAvatarForSelf(): Avatar {
|
||||||
|
val initials = NameUtil.getAbbreviation(Recipient.self().getDisplayName(applicationContext))
|
||||||
|
|
||||||
|
return if (initials.isNullOrBlank()) {
|
||||||
|
Avatar.getDefaultForSelf()
|
||||||
|
} else {
|
||||||
|
Avatar.Text(initials, requireNotNull(Avatars.colorMap[Recipient.self().avatarColor.serialize()]), Avatar.DatabaseId.DoNotPersist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultAvatarForGroup(groupId: GroupId): Avatar {
|
||||||
|
val recipient = Recipient.externalGroupExact(applicationContext, groupId)
|
||||||
|
|
||||||
|
return getDefaultAvatarForGroup(recipient.avatarColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDefaultAvatarForGroup(color: AvatarColor?): Avatar {
|
||||||
|
val colorPair = Avatars.colorMap[color?.serialize()]
|
||||||
|
val defaultColor = Avatar.getDefaultForGroup()
|
||||||
|
|
||||||
|
return if (colorPair != null) {
|
||||||
|
defaultColor.copy(color = colorPair)
|
||||||
|
} else {
|
||||||
|
defaultColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(avatar: Avatar, onDelete: () -> Unit) {
|
||||||
|
SignalExecutors.BOUNDED.execute {
|
||||||
|
if (avatar.databaseId is Avatar.DatabaseId.Saved) {
|
||||||
|
val avatarDatabase = SignalDatabase.avatarPicker
|
||||||
|
avatarDatabase.deleteAvatar(avatar)
|
||||||
|
}
|
||||||
|
onDelete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.picker
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
|
||||||
|
data class AvatarPickerState(
|
||||||
|
val currentAvatar: Avatar? = null,
|
||||||
|
val selectableAvatars: List<Avatar> = listOf(),
|
||||||
|
val canSave: Boolean = false,
|
||||||
|
val canClear: Boolean = false,
|
||||||
|
val isCleared: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.picker
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import io.reactivex.rxjava3.core.Single
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.groups.GroupId
|
||||||
|
import org.thoughtcrime.securesms.mediasend.Media
|
||||||
|
import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
|
|
||||||
|
sealed class AvatarPickerViewModel(private val repository: AvatarPickerRepository) : ViewModel() {
|
||||||
|
|
||||||
|
private val disposables = CompositeDisposable()
|
||||||
|
private val store = Store(AvatarPickerState())
|
||||||
|
|
||||||
|
val state: LiveData<AvatarPickerState> = store.stateLiveData
|
||||||
|
|
||||||
|
protected abstract fun getAvatar(): Single<Avatar>
|
||||||
|
protected abstract fun getDefaultAvatarFromRepository(): Avatar
|
||||||
|
protected abstract fun getPersistedAvatars(): Single<List<Avatar>>
|
||||||
|
protected abstract fun getDefaultAvatars(): Single<List<Avatar>>
|
||||||
|
protected abstract fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit)
|
||||||
|
protected abstract fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit)
|
||||||
|
|
||||||
|
fun delete(avatar: Avatar) {
|
||||||
|
repository.delete(avatar) {
|
||||||
|
refreshAvatar()
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
store.update {
|
||||||
|
val avatar = getDefaultAvatarFromRepository()
|
||||||
|
it.copy(currentAvatar = avatar, canSave = true, canClear = false, isCleared = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(onSaved: (Media) -> Unit, onCleared: () -> Unit) {
|
||||||
|
if (store.state.isCleared) {
|
||||||
|
onCleared()
|
||||||
|
} else {
|
||||||
|
val avatar = store.state.currentAvatar ?: throw AssertionError()
|
||||||
|
persistAndCreateMedia(avatar, onSaved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAvatarSelectedFromGrid(avatar: Avatar) {
|
||||||
|
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAvatarEditCompleted(avatar: Avatar) {
|
||||||
|
persistAvatar(avatar) { saved ->
|
||||||
|
store.update { it.copy(currentAvatar = saved, canSave = isSaveable(saved), canClear = true, isCleared = false) }
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAvatarPhotoSelectionCompleted(media: Media) {
|
||||||
|
repository.writeMediaToMultiSessionStorage(media) { multiSessionUri ->
|
||||||
|
persistAvatar(Avatar.Photo(multiSessionUri, media.size, Avatar.DatabaseId.NotSet)) { avatar ->
|
||||||
|
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = true, isCleared = false) }
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun refreshAvatar() {
|
||||||
|
disposables.add(
|
||||||
|
getAvatar().subscribeOn(Schedulers.io()).subscribe { avatar ->
|
||||||
|
store.update { it.copy(currentAvatar = avatar, canSave = isSaveable(avatar), canClear = avatar is Avatar.Photo && !isSaveable(avatar), isCleared = false) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun refreshSelectableAvatars() {
|
||||||
|
disposables.add(
|
||||||
|
Single.zip(getPersistedAvatars(), getDefaultAvatars()) { custom, def ->
|
||||||
|
val customKeys = custom.filterIsInstance(Avatar.Vector::class.java).map { it.key }
|
||||||
|
custom + def.filterNot {
|
||||||
|
it is Avatar.Vector && customKeys.contains(it.key)
|
||||||
|
}
|
||||||
|
}.subscribeOn(Schedulers.io()).subscribe { avatars ->
|
||||||
|
store.update { it.copy(selectableAvatars = avatars) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isSaveable(avatar: Avatar) = avatar.databaseId != Avatar.DatabaseId.DoNotPersist
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
disposables.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SelfAvatarPickerViewModel(private val repository: AvatarPickerRepository) : AvatarPickerViewModel(repository) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshAvatar()
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAvatar(): Single<Avatar> = repository.getAvatarForSelf()
|
||||||
|
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForSelf()
|
||||||
|
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForSelf()
|
||||||
|
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForSelf()
|
||||||
|
|
||||||
|
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||||
|
repository.persistAvatarForSelf(avatar, onPersisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||||
|
repository.persistAndCreateMediaForSelf(avatar, onSaved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class GroupAvatarPickerViewModel(
|
||||||
|
private val groupId: GroupId,
|
||||||
|
private val repository: AvatarPickerRepository,
|
||||||
|
groupAvatarMedia: Media?
|
||||||
|
) : AvatarPickerViewModel(repository) {
|
||||||
|
|
||||||
|
private val initialAvatar: Avatar? = groupAvatarMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshAvatar()
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAvatar(): Single<Avatar> {
|
||||||
|
return if (initialAvatar != null) {
|
||||||
|
Single.just(initialAvatar)
|
||||||
|
} else {
|
||||||
|
repository.getAvatarForGroup(groupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(groupId)
|
||||||
|
override fun getPersistedAvatars(): Single<List<Avatar>> = repository.getPersistedAvatarsForGroup(groupId)
|
||||||
|
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||||
|
|
||||||
|
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) {
|
||||||
|
repository.persistAvatarForGroup(avatar, groupId, onPersisted)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) {
|
||||||
|
repository.persistAndCreateMediaForGroup(avatar, groupId, onSaved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NewGroupAvatarPickerViewModel(
|
||||||
|
private val repository: AvatarPickerRepository,
|
||||||
|
initialMedia: Media?
|
||||||
|
) : AvatarPickerViewModel(repository) {
|
||||||
|
|
||||||
|
private val initialAvatar: Avatar? = initialMedia?.let { Avatar.Photo(it.uri, it.size, Avatar.DatabaseId.DoNotPersist) }
|
||||||
|
|
||||||
|
init {
|
||||||
|
refreshAvatar()
|
||||||
|
refreshSelectableAvatars()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAvatar(): Single<Avatar> {
|
||||||
|
return if (initialAvatar != null) {
|
||||||
|
Single.just(initialAvatar)
|
||||||
|
} else {
|
||||||
|
Single.fromCallable { getDefaultAvatarFromRepository() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getDefaultAvatarFromRepository(): Avatar = repository.getDefaultAvatarForGroup(null)
|
||||||
|
override fun getPersistedAvatars(): Single<List<Avatar>> = Single.just(listOf())
|
||||||
|
override fun getDefaultAvatars(): Single<List<Avatar>> = repository.getDefaultAvatarsForGroup()
|
||||||
|
override fun persistAvatar(avatar: Avatar, onPersisted: (Avatar) -> Unit) = onPersisted(avatar)
|
||||||
|
override fun persistAndCreateMedia(avatar: Avatar, onSaved: (Media) -> Unit) = repository.createMediaForNewGroup(avatar, onSaved)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(
|
||||||
|
private val repository: AvatarPickerRepository,
|
||||||
|
private val groupId: GroupId?,
|
||||||
|
private val isNewGroup: Boolean,
|
||||||
|
private val groupAvatarMedia: Media?
|
||||||
|
) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
val viewModel = if (groupId == null && !isNewGroup) {
|
||||||
|
SelfAvatarPickerViewModel(repository)
|
||||||
|
} else if (groupId == null) {
|
||||||
|
NewGroupAvatarPickerViewModel(repository, groupAvatarMedia)
|
||||||
|
} else {
|
||||||
|
GroupAvatarPickerViewModel(groupId, repository, groupAvatarMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
return requireNotNull(modelClass.cast(viewModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.text
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.EditText
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
import androidx.constraintlayout.widget.ConstraintSet
|
||||||
|
import androidx.core.widget.doAfterTextChanged
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.Navigation
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.transition.AutoTransition
|
||||||
|
import androidx.transition.TransitionManager
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import org.signal.core.util.EditTextUtil
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.avatar.picker.AvatarPickerItem
|
||||||
|
import org.thoughtcrime.securesms.components.BoldSelectionTabItem
|
||||||
|
import org.thoughtcrime.securesms.components.ControllableTabLayout
|
||||||
|
import org.thoughtcrime.securesms.components.KeyboardAwareLinearLayout
|
||||||
|
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||||
|
import org.thoughtcrime.securesms.util.ViewUtil
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment to create an avatar based off of a Vector or Text (via a pager)
|
||||||
|
*/
|
||||||
|
class TextAvatarCreationFragment : Fragment(R.layout.text_avatar_creation_fragment) {
|
||||||
|
|
||||||
|
private val viewModel: TextAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
|
||||||
|
|
||||||
|
private lateinit var textInput: EditText
|
||||||
|
private lateinit var recycler: RecyclerView
|
||||||
|
private lateinit var content: ConstraintLayout
|
||||||
|
|
||||||
|
private val withRecyclerSet = ConstraintSet()
|
||||||
|
private val withoutRecyclerSet = ConstraintSet()
|
||||||
|
|
||||||
|
private var hasBoundFromViewModel: Boolean = false
|
||||||
|
|
||||||
|
private fun createFactory(): TextAvatarCreationViewModel.Factory {
|
||||||
|
val args = TextAvatarCreationFragmentArgs.fromBundle(requireArguments())
|
||||||
|
val textBundle = args.textAvatar
|
||||||
|
val text = if (textBundle != null) {
|
||||||
|
AvatarBundler.extractText(textBundle)
|
||||||
|
} else {
|
||||||
|
Avatar.Text("", Avatars.colors.random(), Avatar.DatabaseId.NotSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TextAvatarCreationViewModel.Factory(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val toolbar: Toolbar = view.findViewById(R.id.text_avatar_creation_toolbar)
|
||||||
|
val tabLayout: ControllableTabLayout = view.findViewById(R.id.text_avatar_creation_tabs)
|
||||||
|
val doneButton: View = view.findViewById(R.id.text_avatar_creation_done)
|
||||||
|
val keyboardAwareLayout: KeyboardAwareLinearLayout = view.findViewById(R.id.keyboard_aware_layout)
|
||||||
|
|
||||||
|
withRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content)
|
||||||
|
withoutRecyclerSet.load(requireContext(), R.layout.text_avatar_creation_fragment_content_hidden_recycler)
|
||||||
|
|
||||||
|
content = view.findViewById(R.id.content)
|
||||||
|
recycler = view.findViewById(R.id.text_avatar_creation_recycler)
|
||||||
|
textInput = view.findViewById(R.id.avatar_picker_item_text)
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener { Navigation.findNavController(it).popBackStack() }
|
||||||
|
BoldSelectionTabItem.registerListeners(tabLayout)
|
||||||
|
|
||||||
|
val onTabSelectedListener = OnTabSelectedListener()
|
||||||
|
tabLayout.addOnTabSelectedListener(onTabSelectedListener)
|
||||||
|
onTabSelectedListener.onTabSelected(requireNotNull(tabLayout.getTabAt(tabLayout.selectedTabPosition)))
|
||||||
|
|
||||||
|
val adapter = MappingAdapter()
|
||||||
|
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||||
|
AvatarColorItem.registerViewHolder(adapter) {
|
||||||
|
viewModel.setColor(it)
|
||||||
|
}
|
||||||
|
recycler.adapter = adapter
|
||||||
|
|
||||||
|
val viewHolder = AvatarPickerItem.ViewHolder(view)
|
||||||
|
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||||
|
EditTextUtil.setCursorColor(textInput, state.currentAvatar.color.foregroundColor)
|
||||||
|
viewHolder.bind(AvatarPickerItem.Model(state.currentAvatar, false))
|
||||||
|
|
||||||
|
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
|
||||||
|
hasBoundFromViewModel = true
|
||||||
|
}
|
||||||
|
|
||||||
|
EditTextUtil.addGraphemeClusterLimitFilter(textInput, 3)
|
||||||
|
textInput.doAfterTextChanged {
|
||||||
|
if (it != null && hasBoundFromViewModel) {
|
||||||
|
viewModel.setText(it.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doneButton.setOnClickListener { v ->
|
||||||
|
setFragmentResult(REQUEST_KEY_TEXT, AvatarBundler.bundleText(viewModel.getCurrentAvatar()))
|
||||||
|
Navigation.findNavController(v).popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
textInput.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_NEXT) {
|
||||||
|
tabLayout.getTabAt(1)?.select()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardAwareLayout.addOnKeyboardHiddenListener {
|
||||||
|
if (tabLayout.selectedTabPosition == 1) {
|
||||||
|
val transition = AutoTransition().setStartDelay(250L)
|
||||||
|
TransitionManager.endTransitions(content)
|
||||||
|
withRecyclerSet.applyTo(content)
|
||||||
|
TransitionManager.beginDelayedTransition(content, transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class OnTabSelectedListener : TabLayout.OnTabSelectedListener {
|
||||||
|
override fun onTabSelected(tab: TabLayout.Tab) {
|
||||||
|
when (tab.position) {
|
||||||
|
0 -> {
|
||||||
|
textInput.isEnabled = true
|
||||||
|
ViewUtil.focusAndShowKeyboard(textInput)
|
||||||
|
|
||||||
|
withoutRecyclerSet.applyTo(content)
|
||||||
|
textInput.setSelection(textInput.length())
|
||||||
|
}
|
||||||
|
1 -> {
|
||||||
|
textInput.isEnabled = false
|
||||||
|
ViewUtil.hideKeyboard(requireContext(), textInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTabUnselected(tab: TabLayout.Tab?) = Unit
|
||||||
|
override fun onTabReselected(tab: TabLayout.Tab?) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_KEY_TEXT = "org.thoughtcrime.securesms.avatar.text.TEXT"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.text
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
|
||||||
|
data class TextAvatarCreationState(
|
||||||
|
val currentAvatar: Avatar.Text,
|
||||||
|
) {
|
||||||
|
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.text
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.Transformations
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
|
|
||||||
|
class TextAvatarCreationViewModel(initialText: Avatar.Text) : ViewModel() {
|
||||||
|
|
||||||
|
private val store = Store(TextAvatarCreationState(initialText))
|
||||||
|
|
||||||
|
val state: LiveData<TextAvatarCreationState> = Transformations.distinctUntilChanged(store.stateLiveData)
|
||||||
|
|
||||||
|
fun setColor(colorPair: Avatars.ColorPair) {
|
||||||
|
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setText(text: String) {
|
||||||
|
store.update {
|
||||||
|
if (it.currentAvatar.text == text) {
|
||||||
|
it
|
||||||
|
} else {
|
||||||
|
it.copy(currentAvatar = it.currentAvatar.copy(text = text))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAvatar(): Avatar.Text {
|
||||||
|
return store.state.currentAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val initialText: Avatar.Text) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return requireNotNull(modelClass.cast(TextAvatarCreationViewModel(initialText)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.vector
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.setFragmentResult
|
||||||
|
import androidx.fragment.app.viewModels
|
||||||
|
import androidx.navigation.Navigation
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.airbnb.lottie.SimpleColorFilter
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarBundler
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration
|
||||||
|
import org.thoughtcrime.securesms.util.ViewUtil
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment to create an avatar based off a default vector.
|
||||||
|
*/
|
||||||
|
class VectorAvatarCreationFragment : Fragment(R.layout.vector_avatar_creation_fragment) {
|
||||||
|
|
||||||
|
private val viewModel: VectorAvatarCreationViewModel by viewModels(factoryProducer = this::createFactory)
|
||||||
|
|
||||||
|
private fun createFactory(): VectorAvatarCreationViewModel.Factory {
|
||||||
|
val args = VectorAvatarCreationFragmentArgs.fromBundle(requireArguments())
|
||||||
|
val vectorBundle = args.vectorAvatar
|
||||||
|
|
||||||
|
return VectorAvatarCreationViewModel.Factory(AvatarBundler.extractVector(vectorBundle))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val toolbar: Toolbar = view.findViewById(R.id.vector_avatar_creation_toolbar)
|
||||||
|
val recycler: RecyclerView = view.findViewById(R.id.vector_avatar_creation_recycler)
|
||||||
|
val doneButton: View = view.findViewById(R.id.vector_avatar_creation_done)
|
||||||
|
val preview: ImageView = view.findViewById(R.id.vector_avatar_creation_image)
|
||||||
|
|
||||||
|
val adapter = MappingAdapter()
|
||||||
|
recycler.adapter = adapter
|
||||||
|
recycler.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(16)))
|
||||||
|
AvatarColorItem.registerViewHolder(adapter) {
|
||||||
|
viewModel.setColor(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.state.observe(viewLifecycleOwner) { state ->
|
||||||
|
preview.background.colorFilter = SimpleColorFilter(state.currentAvatar.color.backgroundColor)
|
||||||
|
preview.setImageResource(requireNotNull(Avatars.getDrawableResource(state.currentAvatar.key)))
|
||||||
|
adapter.submitList(state.colors().map { AvatarColorItem.Model(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.setNavigationOnClickListener { Navigation.findNavController(view).popBackStack() }
|
||||||
|
doneButton.setOnClickListener {
|
||||||
|
setFragmentResult(REQUEST_KEY_VECTOR, AvatarBundler.bundleVector(viewModel.getCurrentAvatar()))
|
||||||
|
Navigation.findNavController(it).popBackStack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val REQUEST_KEY_VECTOR = "org.thoughtcrime.securesms.avatar.text.VECTOR"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.vector
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.AvatarColorItem
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
|
||||||
|
data class VectorAvatarCreationState(
|
||||||
|
val currentAvatar: Avatar.Vector,
|
||||||
|
) {
|
||||||
|
fun colors(): List<AvatarColorItem> = Avatars.colors.map { AvatarColorItem(it, currentAvatar.color == it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.thoughtcrime.securesms.avatar.vector
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatar
|
||||||
|
import org.thoughtcrime.securesms.avatar.Avatars
|
||||||
|
import org.thoughtcrime.securesms.util.livedata.Store
|
||||||
|
|
||||||
|
class VectorAvatarCreationViewModel(initialAvatar: Avatar.Vector) : ViewModel() {
|
||||||
|
|
||||||
|
private val store = Store(VectorAvatarCreationState(initialAvatar))
|
||||||
|
|
||||||
|
val state: LiveData<VectorAvatarCreationState> = store.stateLiveData
|
||||||
|
|
||||||
|
fun setColor(colorPair: Avatars.ColorPair) {
|
||||||
|
store.update { it.copy(currentAvatar = it.currentAvatar.copy(color = colorPair)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCurrentAvatar() = store.state.currentAvatar
|
||||||
|
|
||||||
|
class Factory(private val initialAvatar: Avatar.Vector) : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||||
|
return requireNotNull(modelClass.cast(VectorAvatarCreationViewModel(initialAvatar)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package org.thoughtcrime.securesms.backup
|
||||||
|
|
||||||
|
import org.thoughtcrime.securesms.database.AttachmentDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.MmsDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.SmsDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queries used by backup exporter to estimate total counts for various complicated tables.
|
||||||
|
*/
|
||||||
|
object BackupCountQueries {
|
||||||
|
|
||||||
|
const val mmsCount: String = "SELECT COUNT(*) FROM ${MmsDatabase.TABLE_NAME} WHERE ${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.VIEW_ONCE} <= 0"
|
||||||
|
|
||||||
|
const val smsCount: String = "SELECT COUNT(*) FROM ${SmsDatabase.TABLE_NAME} WHERE ${SmsDatabase.EXPIRES_IN} <= 0"
|
||||||
|
|
||||||
|
@get:JvmStatic
|
||||||
|
val groupReceiptCount: String = """
|
||||||
|
SELECT COUNT(*) FROM ${GroupReceiptDatabase.TABLE_NAME}
|
||||||
|
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${GroupReceiptDatabase.TABLE_NAME}.${GroupReceiptDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
|
||||||
|
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@get:JvmStatic
|
||||||
|
val attachmentCount: String = """
|
||||||
|
SELECT COUNT(*) FROM ${AttachmentDatabase.TABLE_NAME}
|
||||||
|
INNER JOIN ${MmsDatabase.TABLE_NAME} ON ${AttachmentDatabase.TABLE_NAME}.${AttachmentDatabase.MMS_ID} = ${MmsDatabase.TABLE_NAME}.${MmsDatabase.ID}
|
||||||
|
WHERE ${MmsDatabase.TABLE_NAME}.${MmsDatabase.EXPIRES_IN} <= 0 AND ${MmsDatabase.TABLE_NAME}.${MmsDatabase.VIEW_ONCE} <= 0
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -13,13 +13,12 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
|
|
||||||
public abstract class FullBackupBase {
|
public abstract class FullBackupBase {
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
private static final int DIGEST_ROUNDS = 250_000;
|
||||||
private static final String TAG = Log.tag(FullBackupBase.class);
|
|
||||||
|
|
||||||
static class BackupStream {
|
static class BackupStream {
|
||||||
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
|
static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) {
|
||||||
try {
|
try {
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
|
||||||
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-512");
|
MessageDigest digest = MessageDigest.getInstance("SHA-512");
|
||||||
byte[] input = passphrase.replace(" ", "").getBytes();
|
byte[] input = passphrase.replace(" ", "").getBytes();
|
||||||
@@ -27,8 +26,8 @@ public abstract class FullBackupBase {
|
|||||||
|
|
||||||
if (salt != null) digest.update(salt);
|
if (salt != null) digest.update(salt);
|
||||||
|
|
||||||
for (int i=0;i<250000;i++) {
|
for (int i = 0; i < DIGEST_ROUNDS; i++) {
|
||||||
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0));
|
if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0));
|
||||||
digest.update(hash);
|
digest.update(hash);
|
||||||
hash = digest.digest(input);
|
hash = digest.digest(input);
|
||||||
}
|
}
|
||||||
@@ -47,20 +46,34 @@ public abstract class FullBackupBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private final Type type;
|
private final Type type;
|
||||||
private final int count;
|
private final long count;
|
||||||
|
private final long estimatedTotalCount;
|
||||||
|
|
||||||
BackupEvent(Type type, int count) {
|
BackupEvent(Type type, long count, long estimatedTotalCount) {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
this.count = count;
|
this.count = count;
|
||||||
|
this.estimatedTotalCount = estimatedTotalCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Type getType() {
|
public Type getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int getCount() {
|
public long getCount() {
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getEstimatedTotalCount() {
|
||||||
|
return estimatedTotalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getCompletionPercentage() {
|
||||||
|
if (estimatedTotalCount == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import androidx.documentfile.provider.DocumentFile;
|
|||||||
import com.annimon.stream.function.Predicate;
|
import com.annimon.stream.function.Predicate;
|
||||||
import com.google.protobuf.ByteString;
|
import com.google.protobuf.ByteString;
|
||||||
|
|
||||||
import net.sqlcipher.database.SQLiteDatabase;
|
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;
|
||||||
@@ -38,11 +38,11 @@ import org.thoughtcrime.securesms.database.SessionDatabase;
|
|||||||
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
import org.thoughtcrime.securesms.database.SignedPreKeyDatabase;
|
||||||
import org.thoughtcrime.securesms.database.SmsDatabase;
|
import org.thoughtcrime.securesms.database.SmsDatabase;
|
||||||
import org.thoughtcrime.securesms.database.StickerDatabase;
|
import org.thoughtcrime.securesms.database.StickerDatabase;
|
||||||
|
import org.thoughtcrime.securesms.database.model.AvatarPickerDatabase;
|
||||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||||
import org.thoughtcrime.securesms.keyvalue.KeyValueDataSet;
|
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.service.PendingRetryReceiptManager;
|
|
||||||
import org.thoughtcrime.securesms.util.SetUtil;
|
import org.thoughtcrime.securesms.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;
|
||||||
@@ -75,6 +75,11 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
|
|
||||||
private static final String TAG = Log.tag(FullBackupExporter.class);
|
private static final String TAG = Log.tag(FullBackupExporter.class);
|
||||||
|
|
||||||
|
private static final long DATABASE_VERSION_RECORD_COUNT = 1L;
|
||||||
|
private static final long TABLE_RECORD_COUNT_MULTIPLIER = 3L;
|
||||||
|
private static final long IDENTITY_KEY_BACKUP_RECORD_COUNT = 2L;
|
||||||
|
private static final long FINAL_MESSAGE_COUNT = 1L;
|
||||||
|
|
||||||
private static final Set<String> BLACKLISTED_TABLES = SetUtil.newHashSet(
|
private static final Set<String> BLACKLISTED_TABLES = SetUtil.newHashSet(
|
||||||
SignedPreKeyDatabase.TABLE_NAME,
|
SignedPreKeyDatabase.TABLE_NAME,
|
||||||
OneTimePreKeyDatabase.TABLE_NAME,
|
OneTimePreKeyDatabase.TABLE_NAME,
|
||||||
@@ -84,7 +89,8 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
EmojiSearchDatabase.TABLE_NAME,
|
EmojiSearchDatabase.TABLE_NAME,
|
||||||
SenderKeyDatabase.TABLE_NAME,
|
SenderKeyDatabase.TABLE_NAME,
|
||||||
SenderKeySharedDatabase.TABLE_NAME,
|
SenderKeySharedDatabase.TABLE_NAME,
|
||||||
PendingRetryReceiptDatabase.TABLE_NAME
|
PendingRetryReceiptDatabase.TABLE_NAME,
|
||||||
|
AvatarPickerDatabase.TABLE_NAME
|
||||||
);
|
);
|
||||||
|
|
||||||
public static void export(@NonNull Context context,
|
public static void export(@NonNull Context context,
|
||||||
@@ -133,58 +139,62 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
@NonNull BackupCancellationSignal cancellationSignal)
|
@NonNull BackupCancellationSignal cancellationSignal)
|
||||||
throws IOException
|
throws IOException
|
||||||
{
|
{
|
||||||
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
|
BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase);
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
long estimatedCountOutside = 0L;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
outputStream.writeDatabaseVersion(input.getVersion());
|
outputStream.writeDatabaseVersion(input.getVersion());
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
List<String> tables = exportSchema(input, outputStream);
|
List<String> tables = exportSchema(input, outputStream);
|
||||||
count += tables.size() * 3;
|
count += tables.size() * TABLE_RECORD_COUNT_MULTIPLIER;
|
||||||
|
|
||||||
|
final long estimatedCount = calculateCount(context, input, tables);
|
||||||
|
estimatedCountOutside = estimatedCount;
|
||||||
|
|
||||||
Stopwatch stopwatch = new Stopwatch("Backup");
|
Stopwatch stopwatch = new Stopwatch("Backup");
|
||||||
|
|
||||||
for (String table : tables) {
|
for (String table : tables) {
|
||||||
throwIfCanceled(cancellationSignal);
|
throwIfCanceled(cancellationSignal);
|
||||||
if (table.equals(MmsDatabase.TABLE_NAME)) {
|
if (table.equals(MmsDatabase.TABLE_NAME)) {
|
||||||
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, cancellationSignal);
|
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringMmsMessage, null, count, estimatedCount, cancellationSignal);
|
||||||
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
|
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
|
||||||
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, cancellationSignal);
|
count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, cancellationSignal);
|
||||||
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
||||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, cancellationSignal);
|
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal);
|
||||||
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
||||||
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
|
count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||||
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
||||||
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount), count, cancellationSignal);
|
count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal);
|
||||||
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
|
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
|
||||||
count = exportTable(table, input, outputStream, null, null, count, cancellationSignal);
|
count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal);
|
||||||
}
|
}
|
||||||
stopwatch.split("table::" + table);
|
stopwatch.split("table::" + table);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
|
for (BackupProtos.SharedPreference preference : IdentityKeyUtil.getBackupRecord(context)) {
|
||||||
throwIfCanceled(cancellationSignal);
|
throwIfCanceled(cancellationSignal);
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(preference);
|
outputStream.write(preference);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
|
for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) {
|
||||||
throwIfCanceled(cancellationSignal);
|
throwIfCanceled(cancellationSignal);
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(preference);
|
outputStream.write(preference);
|
||||||
}
|
}
|
||||||
|
|
||||||
stopwatch.split("prefs");
|
stopwatch.split("prefs");
|
||||||
|
|
||||||
count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, cancellationSignal);
|
count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, estimatedCount, cancellationSignal);
|
||||||
|
|
||||||
stopwatch.split("key_values");
|
stopwatch.split("key_values");
|
||||||
|
|
||||||
for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
|
for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) {
|
||||||
throwIfCanceled(cancellationSignal);
|
throwIfCanceled(cancellationSignal);
|
||||||
if (avatar != null) {
|
if (avatar != null) {
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
|
outputStream.write(avatar.getFilename(), avatar.getInputStream(), avatar.getLength());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +207,49 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
if (closeOutputStream) {
|
if (closeOutputStream) {
|
||||||
outputStream.close();
|
outputStream.close();
|
||||||
}
|
}
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long calculateCount(@NonNull Context context, @NonNull SQLiteDatabase input, List<String> tables) {
|
||||||
|
long count = DATABASE_VERSION_RECORD_COUNT + TABLE_RECORD_COUNT_MULTIPLIER * tables.size();
|
||||||
|
|
||||||
|
for (String table : tables) {
|
||||||
|
if (table.equals(MmsDatabase.TABLE_NAME)) {
|
||||||
|
count += getCount(input, BackupCountQueries.mmsCount);
|
||||||
|
} else if (table.equals(SmsDatabase.TABLE_NAME)) {
|
||||||
|
count += getCount(input, BackupCountQueries.smsCount);
|
||||||
|
} else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) {
|
||||||
|
count += getCount(input, BackupCountQueries.getGroupReceiptCount());
|
||||||
|
} else if (table.equals(AttachmentDatabase.TABLE_NAME)) {
|
||||||
|
count += getCount(input, BackupCountQueries.getAttachmentCount());
|
||||||
|
} else if (table.equals(StickerDatabase.TABLE_NAME)) {
|
||||||
|
count += getCount(input, "SELECT COUNT(*) FROM " + table);
|
||||||
|
} else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) {
|
||||||
|
count += getCount(input, "SELECT COUNT(*) FROM " + table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count += IDENTITY_KEY_BACKUP_RECORD_COUNT;
|
||||||
|
|
||||||
|
count += TextSecurePreferences.getPreferencesToSaveToBackupCount(context);
|
||||||
|
|
||||||
|
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
|
||||||
|
.getDataSet();
|
||||||
|
for (String key : SignalStore.getKeysToIncludeInBackup()) {
|
||||||
|
if (dataSet.containsKey(key)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
count += AvatarHelper.getAvatarCount(context);
|
||||||
|
|
||||||
|
return count + FINAL_MESSAGE_COUNT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long getCount(@NonNull SQLiteDatabase input, @NonNull String query) {
|
||||||
|
try (Cursor cursor = input.rawQuery(query)) {
|
||||||
|
return cursor.moveToFirst() ? cursor.getLong(0) : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +295,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
@Nullable Predicate<Cursor> predicate,
|
@Nullable Predicate<Cursor> predicate,
|
||||||
@Nullable PostProcessor postProcess,
|
@Nullable PostProcessor postProcess,
|
||||||
int count,
|
int count,
|
||||||
|
long estimatedCount,
|
||||||
@NonNull BackupCancellationSignal cancellationSignal)
|
@NonNull BackupCancellationSignal cancellationSignal)
|
||||||
throws IOException
|
throws IOException
|
||||||
{
|
{
|
||||||
@@ -282,7 +335,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
|
|
||||||
statement.append(')');
|
statement.append(')');
|
||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
|
outputStream.write(statementBuilder.setStatement(statement.toString()).build());
|
||||||
|
|
||||||
if (postProcess != null) {
|
if (postProcess != null) {
|
||||||
@@ -295,7 +348,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
|
private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
|
||||||
try {
|
try {
|
||||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID));
|
||||||
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID));
|
||||||
@@ -320,7 +373,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||||
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data));
|
||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
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);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -330,7 +383,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count) {
|
private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) {
|
||||||
try {
|
try {
|
||||||
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID));
|
||||||
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH));
|
||||||
@@ -339,7 +392,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM));
|
||||||
|
|
||||||
if (!TextUtils.isEmpty(data) && size > 0) {
|
if (!TextUtils.isEmpty(data) && size > 0) {
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0);
|
||||||
outputStream.writeSticker(rowId, inputStream, size);
|
outputStream.writeSticker(rowId, inputStream, size);
|
||||||
}
|
}
|
||||||
@@ -370,6 +423,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream,
|
private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream,
|
||||||
@NonNull List<String> keysToIncludeInBackup,
|
@NonNull List<String> keysToIncludeInBackup,
|
||||||
int count,
|
int count,
|
||||||
|
long estimatedCount,
|
||||||
BackupCancellationSignal cancellationSignal) throws IOException
|
BackupCancellationSignal cancellationSignal) throws IOException
|
||||||
{
|
{
|
||||||
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
|
KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication())
|
||||||
@@ -400,7 +454,7 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
throw new AssertionError("Unknown type: " + type);
|
throw new AssertionError("Unknown type: " + type);
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount));
|
||||||
outputStream.write(builder.build());
|
outputStream.write(builder.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +462,12 @@ public class FullBackupExporter extends FullBackupBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
|
private static boolean isNonExpiringMmsMessage(@NonNull Cursor cursor) {
|
||||||
return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
|
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0 &&
|
||||||
cursor.getInt(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
|
cursor.getLong(cursor.getColumnIndexOrThrow(MmsDatabase.VIEW_ONCE)) <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
|
private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) {
|
||||||
return cursor.getInt(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
|
return cursor.getLong(cursor.getColumnIndexOrThrow(MmsSmsColumns.EXPIRES_IN)) <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, long mmsId) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import android.util.Pair;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import net.sqlcipher.database.SQLiteDatabase;
|
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;
|
||||||
@@ -95,7 +95,7 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
BackupFrame frame;
|
BackupFrame frame;
|
||||||
|
|
||||||
while (!(frame = inputStream.readFrame()).getEnd()) {
|
while (!(frame = inputStream.readFrame()).getEnd()) {
|
||||||
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count));
|
if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0));
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
if (frame.hasVersion()) processVersion(db, frame.getVersion());
|
if (frame.hasVersion()) processVersion(db, frame.getVersion());
|
||||||
@@ -115,7 +115,7 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
keyValueDatabase.endTransaction();
|
keyValueDatabase.endTransaction();
|
||||||
}
|
}
|
||||||
|
|
||||||
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count));
|
EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{
|
private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{
|
||||||
@@ -162,9 +162,8 @@ public class FullBackupImporter extends FullBackupBase {
|
|||||||
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
private static void processAttachment(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase db, @NonNull Attachment attachment, BackupRecordInputStream inputStream)
|
||||||
throws IOException
|
throws IOException
|
||||||
{
|
{
|
||||||
File partsDirectory = context.getDir(AttachmentDatabase.DIRECTORY, Context.MODE_PRIVATE);
|
File dataFile = AttachmentDatabase.newFile(context);
|
||||||
File dataFile = File.createTempFile("part", ".mms", partsDirectory);
|
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
||||||
Pair<byte[], OutputStream> output = ModernEncryptingPartOutputStream.createFor(attachmentSecret, dataFile, false);
|
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import androidx.appcompat.widget.AppCompatImageView
|
||||||
|
import androidx.core.content.res.use
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideRequests
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||||
|
import java.lang.IllegalArgumentException
|
||||||
|
|
||||||
|
class BadgeImageView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : AppCompatImageView(context, attrs) {
|
||||||
|
|
||||||
|
private var badgeSize: Int = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
context.obtainStyledAttributes(attrs, R.styleable.BadgeImageView).use {
|
||||||
|
badgeSize = it.getInt(R.styleable.BadgeImageView_badge_size, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
isClickable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setOnClickListener(l: OnClickListener?) {
|
||||||
|
val wasClickable = isClickable
|
||||||
|
super.setOnClickListener(l)
|
||||||
|
this.isClickable = wasClickable
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBadgeFromRecipient(recipient: Recipient?) {
|
||||||
|
getGlideRequests()?.let {
|
||||||
|
setBadgeFromRecipient(recipient, it)
|
||||||
|
} ?: clearDrawable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBadgeFromRecipient(recipient: Recipient?, glideRequests: GlideRequests) {
|
||||||
|
if (recipient == null || recipient.badges.isEmpty()) {
|
||||||
|
setBadge(null, glideRequests)
|
||||||
|
} else if (recipient.isSelf) {
|
||||||
|
val badge = recipient.featuredBadge
|
||||||
|
if (badge == null || !badge.visible || badge.isExpired()) {
|
||||||
|
setBadge(null, glideRequests)
|
||||||
|
} else {
|
||||||
|
setBadge(badge, glideRequests)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBadge(recipient.featuredBadge, glideRequests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBadge(badge: Badge?) {
|
||||||
|
getGlideRequests()?.let {
|
||||||
|
setBadge(badge, it)
|
||||||
|
} ?: clearDrawable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBadge(badge: Badge?, glideRequests: GlideRequests) {
|
||||||
|
if (badge != null) {
|
||||||
|
glideRequests
|
||||||
|
.load(badge)
|
||||||
|
.downsample(DownsampleStrategy.NONE)
|
||||||
|
.transform(BadgeSpriteTransformation(BadgeSpriteTransformation.Size.fromInteger(badgeSize), badge.imageDensity, ThemeUtil.isDarkTheme(context)))
|
||||||
|
.into(this)
|
||||||
|
|
||||||
|
isClickable = true
|
||||||
|
} else {
|
||||||
|
glideRequests
|
||||||
|
.clear(this)
|
||||||
|
clearDrawable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearDrawable() {
|
||||||
|
setImageDrawable(null)
|
||||||
|
isClickable = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGlideRequests(): GlideRequests? {
|
||||||
|
return try {
|
||||||
|
GlideApp.with(this)
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// View not attached to an activity or activity destroyed
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import io.reactivex.rxjava3.core.Completable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.database.RecipientDatabase
|
||||||
|
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.storage.StorageSyncHelper
|
||||||
|
import org.thoughtcrime.securesms.util.ProfileUtil
|
||||||
|
|
||||||
|
class BadgeRepository(context: Context) {
|
||||||
|
|
||||||
|
private val context = context.applicationContext
|
||||||
|
|
||||||
|
fun setVisibilityForAllBadges(
|
||||||
|
displayBadgesOnProfile: Boolean,
|
||||||
|
selfBadges: List<Badge> = Recipient.self().badges
|
||||||
|
): Completable = Completable.fromAction {
|
||||||
|
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
|
||||||
|
val badges = selfBadges.map { it.copy(visible = displayBadgesOnProfile) }
|
||||||
|
|
||||||
|
ProfileUtil.uploadProfileWithBadges(context, badges)
|
||||||
|
SignalStore.donationsValues().setDisplayBadgesOnProfile(displayBadgesOnProfile)
|
||||||
|
recipientDatabase.markNeedsSync(Recipient.self().id)
|
||||||
|
StorageSyncHelper.scheduleSyncForDataChange()
|
||||||
|
|
||||||
|
recipientDatabase.setBadges(Recipient.self().id, badges)
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
|
||||||
|
fun setFeaturedBadge(featuredBadge: Badge): Completable = Completable.fromAction {
|
||||||
|
val badges = Recipient.self().badges
|
||||||
|
val reOrderedBadges = listOf(featuredBadge.copy(visible = true)) + (badges.filterNot { it.id == featuredBadge.id })
|
||||||
|
ProfileUtil.uploadProfileWithBadges(context, reOrderedBadges)
|
||||||
|
|
||||||
|
val recipientDatabase: RecipientDatabase = SignalDatabase.recipients
|
||||||
|
recipientDatabase.setBadges(Recipient.self().id, reOrderedBadges)
|
||||||
|
}.subscribeOn(Schedulers.io())
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.flexbox.AlignItems
|
||||||
|
import com.google.android.flexbox.FlexDirection
|
||||||
|
import com.google.android.flexbox.FlexboxLayoutManager
|
||||||
|
import com.google.android.flexbox.JustifyContent
|
||||||
|
import org.signal.core.util.DimensionUnit
|
||||||
|
import org.signal.core.util.logging.Log
|
||||||
|
import org.thoughtcrime.securesms.BuildConfig
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge.Category.Companion.fromCode
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
|
import org.thoughtcrime.securesms.database.model.databaseprotos.BadgeList
|
||||||
|
import org.thoughtcrime.securesms.util.ScreenDensity
|
||||||
|
import org.whispersystems.libsignal.util.Pair
|
||||||
|
import org.whispersystems.signalservice.api.profiles.SignalServiceProfile
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.sql.Timestamp
|
||||||
|
|
||||||
|
object Badges {
|
||||||
|
|
||||||
|
private val TAG: String = Log.tag(Badges::class.java)
|
||||||
|
|
||||||
|
fun DSLConfiguration.displayBadges(
|
||||||
|
context: Context,
|
||||||
|
badges: List<Badge>,
|
||||||
|
selectedBadge: Badge? = null,
|
||||||
|
fadedBadgeId: String? = null
|
||||||
|
) {
|
||||||
|
badges
|
||||||
|
.map {
|
||||||
|
Badge.Model(
|
||||||
|
badge = it,
|
||||||
|
isSelected = it == selectedBadge,
|
||||||
|
isFaded = it.id == fadedBadgeId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.forEach { customPref(it) }
|
||||||
|
|
||||||
|
val badgeSize = DimensionUnit.DP.toPixels(88f)
|
||||||
|
val windowWidth = context.resources.displayMetrics.widthPixels
|
||||||
|
val perRow = (windowWidth / badgeSize).toInt()
|
||||||
|
|
||||||
|
val empties = ((perRow - (badges.size % perRow)) % perRow)
|
||||||
|
repeat(empties) {
|
||||||
|
customPref(Badge.EmptyModel())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLayoutManagerForGridWithBadges(context: Context): RecyclerView.LayoutManager {
|
||||||
|
val layoutManager = FlexboxLayoutManager(context)
|
||||||
|
|
||||||
|
layoutManager.flexDirection = FlexDirection.ROW
|
||||||
|
layoutManager.alignItems = AlignItems.CENTER
|
||||||
|
layoutManager.justifyContent = JustifyContent.CENTER
|
||||||
|
|
||||||
|
return layoutManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBadgeImageUri(densityPath: String): Uri {
|
||||||
|
return Uri.parse(BuildConfig.BADGE_STATIC_ROOT).buildUpon()
|
||||||
|
.appendPath(densityPath)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBestBadgeImageUriForDevice(serviceBadge: SignalServiceProfile.Badge): Pair<Uri, String> {
|
||||||
|
return when (ScreenDensity.getBestDensityBucketForDevice()) {
|
||||||
|
"ldpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[0]), "ldpi")
|
||||||
|
"mdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[1]), "mdpi")
|
||||||
|
"hdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[2]), "hdpi")
|
||||||
|
"xxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[4]), "xxhdpi")
|
||||||
|
"xxxhdpi" -> Pair(getBadgeImageUri(serviceBadge.sprites6[5]), "xxxhdpi")
|
||||||
|
else -> Pair(getBadgeImageUri(serviceBadge.sprites6[3]), "xhdpi")
|
||||||
|
}.also {
|
||||||
|
Log.d(TAG, "Selected badge density ${it.second()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTimestamp(bigDecimal: BigDecimal): Long {
|
||||||
|
return Timestamp(bigDecimal.toLong() * 1000).time
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun fromDatabaseBadge(badge: BadgeList.Badge): Badge {
|
||||||
|
return Badge(
|
||||||
|
badge.id,
|
||||||
|
fromCode(badge.category),
|
||||||
|
badge.name,
|
||||||
|
badge.description,
|
||||||
|
Uri.parse(badge.imageUrl),
|
||||||
|
badge.imageDensity,
|
||||||
|
badge.expiration,
|
||||||
|
badge.visible
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun toDatabaseBadge(badge: Badge): BadgeList.Badge {
|
||||||
|
return BadgeList.Badge.newBuilder()
|
||||||
|
.setId(badge.id)
|
||||||
|
.setCategory(badge.category.code)
|
||||||
|
.setDescription(badge.description)
|
||||||
|
.setExpiration(badge.expirationTimestamp)
|
||||||
|
.setVisible(badge.visible)
|
||||||
|
.setName(badge.name)
|
||||||
|
.setImageUrl(badge.imageUrl.toString())
|
||||||
|
.setImageDensity(badge.imageDensity)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun fromServiceBadge(serviceBadge: SignalServiceProfile.Badge): Badge {
|
||||||
|
val uriAndDensity: Pair<Uri, String> = getBestBadgeImageUriForDevice(serviceBadge)
|
||||||
|
return Badge(
|
||||||
|
serviceBadge.id,
|
||||||
|
fromCode(serviceBadge.category),
|
||||||
|
serviceBadge.name,
|
||||||
|
serviceBadge.description,
|
||||||
|
uriAndDensity.first(),
|
||||||
|
uriAndDensity.second(),
|
||||||
|
serviceBadge.expiration?.let { getTimestamp(it) } ?: 0,
|
||||||
|
serviceBadge.isVisible
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.glide
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Rect
|
||||||
|
import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.BitmapTransformation
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cuts out the badge of the requested size from the sprite sheet.
|
||||||
|
*/
|
||||||
|
class BadgeSpriteTransformation(
|
||||||
|
private val size: Size,
|
||||||
|
private val density: String,
|
||||||
|
private val isDarkTheme: Boolean
|
||||||
|
) : BitmapTransformation() {
|
||||||
|
|
||||||
|
private val id = "BadgeSpriteTransformation(${size.code},$density,$isDarkTheme).$VERSION"
|
||||||
|
|
||||||
|
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||||
|
messageDigest.update(id.toByteArray(CHARSET))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
return (other as? BadgeSpriteTransformation)?.id == id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return id.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun transform(pool: BitmapPool, toTransform: Bitmap, outWidth: Int, outHeight: Int): Bitmap {
|
||||||
|
val outBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(outBitmap)
|
||||||
|
val inBounds = getInBounds(density, size, isDarkTheme)
|
||||||
|
val outBounds = Rect(0, 0, outWidth, outHeight)
|
||||||
|
|
||||||
|
canvas.drawBitmap(toTransform, inBounds, outBounds, null)
|
||||||
|
|
||||||
|
return outBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Size(val code: String, val frameMap: Map<Density, FrameSet>) {
|
||||||
|
SMALL(
|
||||||
|
"small",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(124, 1, 12, 12), Frame(145, 31, 12, 12)),
|
||||||
|
Density.MDPI to FrameSet(Frame(163, 1, 16, 16), Frame(189, 39, 16, 16)),
|
||||||
|
Density.HDPI to FrameSet(Frame(244, 1, 24, 24), Frame(283, 58, 24, 24)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(323, 1, 32, 32), Frame(373, 75, 32, 32)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(483, 1, 48, 48), Frame(557, 111, 48, 48)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(643, 1, 64, 64), Frame(741, 147, 64, 64))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
MEDIUM(
|
||||||
|
"medium",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(124, 16, 18, 18), Frame(160, 31, 18, 18)),
|
||||||
|
Density.MDPI to FrameSet(Frame(163, 19, 24, 24), Frame(207, 39, 24, 24)),
|
||||||
|
Density.HDPI to FrameSet(Frame(244, 28, 36, 36), Frame(310, 58, 36, 36)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(323, 35, 48, 48), Frame(407, 75, 48, 48)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(483, 51, 72, 72), Frame(607, 111, 72, 72)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(643, 67, 96, 96), Frame(807, 147, 96, 96))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
LARGE(
|
||||||
|
"large",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(145, 1, 27, 27), Frame(124, 46, 27, 27)),
|
||||||
|
Density.MDPI to FrameSet(Frame(189, 1, 36, 36), Frame(163, 57, 36, 36)),
|
||||||
|
Density.HDPI to FrameSet(Frame(283, 1, 54, 54), Frame(244, 85, 54, 54)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(373, 1, 72, 72), Frame(323, 109, 72, 72)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(557, 1, 108, 108), Frame(483, 161, 108, 108)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(741, 1, 144, 144), Frame(643, 213, 144, 144))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
BADGE_64(
|
||||||
|
"badge_64",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(124, 73, 48, 48), Frame(124, 73, 48, 48)),
|
||||||
|
Density.MDPI to FrameSet(Frame(163, 97, 64, 64), Frame(163, 97, 64, 64)),
|
||||||
|
Density.HDPI to FrameSet(Frame(244, 145, 96, 96), Frame(244, 145, 96, 96)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(323, 193, 128, 128), Frame(323, 193, 128, 128)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(483, 289, 192, 192), Frame(483, 289, 192, 192)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(643, 385, 256, 256), Frame(643, 385, 256, 256))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
BADGE_112(
|
||||||
|
"badge_112",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(181, 1, 84, 84), Frame(181, 1, 84, 84)),
|
||||||
|
Density.MDPI to FrameSet(Frame(233, 1, 112, 112), Frame(233, 1, 112, 112)),
|
||||||
|
Density.HDPI to FrameSet(Frame(349, 1, 168, 168), Frame(349, 1, 168, 168)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(457, 1, 224, 224), Frame(457, 1, 224, 224)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(681, 1, 336, 336), Frame(681, 1, 336, 336)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(905, 1, 448, 448), Frame(905, 1, 448, 448))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
XLARGE(
|
||||||
|
"xlarge",
|
||||||
|
mapOf(
|
||||||
|
Density.LDPI to FrameSet(Frame(1, 1, 120, 120), Frame(1, 1, 120, 120)),
|
||||||
|
Density.MDPI to FrameSet(Frame(1, 1, 160, 160), Frame(1, 1, 160, 160)),
|
||||||
|
Density.HDPI to FrameSet(Frame(1, 1, 240, 240), Frame(1, 1, 240, 240)),
|
||||||
|
Density.XHDPI to FrameSet(Frame(1, 1, 320, 320), Frame(1, 1, 320, 320)),
|
||||||
|
Density.XXHDPI to FrameSet(Frame(1, 1, 480, 480), Frame(1, 1, 480, 480)),
|
||||||
|
Density.XXXHDPI to FrameSet(Frame(1, 1, 640, 640), Frame(1, 1, 640, 640))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromInteger(integer: Int): Size {
|
||||||
|
return when (integer) {
|
||||||
|
0 -> SMALL
|
||||||
|
1 -> MEDIUM
|
||||||
|
2 -> LARGE
|
||||||
|
3 -> XLARGE
|
||||||
|
4 -> BADGE_64
|
||||||
|
5 -> BADGE_112
|
||||||
|
else -> LARGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Density(val density: String) {
|
||||||
|
LDPI("ldpi"),
|
||||||
|
MDPI("mdpi"),
|
||||||
|
HDPI("hdpi"),
|
||||||
|
XHDPI("xhdpi"),
|
||||||
|
XXHDPI("xxhdpi"),
|
||||||
|
XXXHDPI("xxxhdpi")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FrameSet(val light: Frame, val dark: Frame)
|
||||||
|
|
||||||
|
data class Frame(
|
||||||
|
val x: Int,
|
||||||
|
val y: Int,
|
||||||
|
val width: Int,
|
||||||
|
val height: Int
|
||||||
|
) {
|
||||||
|
fun toBounds(): Rect {
|
||||||
|
return Rect(x, y, x + width, y + height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VERSION = 3
|
||||||
|
|
||||||
|
private fun getDensity(density: String): Density {
|
||||||
|
return Density.values().first { it.density == density }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFrame(size: Size, density: Density, isDarkTheme: Boolean): Frame {
|
||||||
|
val frameSet: FrameSet = size.frameMap[density]!!
|
||||||
|
return if (isDarkTheme) frameSet.dark else frameSet.light
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getInBounds(density: String, size: Size, isDarkTheme: Boolean): Rect {
|
||||||
|
return getFrame(size, getDensity(density), isDarkTheme).toBounds()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.models
|
||||||
|
|
||||||
|
import android.animation.ObjectAnimator
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Parcelable
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import com.bumptech.glide.load.Key
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.glide.BadgeSpriteTransformation
|
||||||
|
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||||
|
import org.thoughtcrime.securesms.mms.GlideApp
|
||||||
|
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Badge that can be collected and displayed by a user.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
data class Badge(
|
||||||
|
val id: String,
|
||||||
|
val category: Category,
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
val imageUrl: Uri,
|
||||||
|
val imageDensity: String,
|
||||||
|
val expirationTimestamp: Long,
|
||||||
|
val visible: Boolean,
|
||||||
|
) : Parcelable, Key {
|
||||||
|
|
||||||
|
fun isExpired(): Boolean = expirationTimestamp < System.currentTimeMillis() && expirationTimestamp > 0
|
||||||
|
fun isBoost(): Boolean = id == BOOST_BADGE_ID
|
||||||
|
|
||||||
|
override fun updateDiskCacheKey(messageDigest: MessageDigest) {
|
||||||
|
messageDigest.update(id.toByteArray(Key.CHARSET))
|
||||||
|
messageDigest.update(imageUrl.toString().toByteArray(Key.CHARSET))
|
||||||
|
messageDigest.update(imageDensity.toByteArray(Key.CHARSET))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveDescription(shortName: String): String {
|
||||||
|
return description.replace("{short_name}", shortName)
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyModel : PreferenceModel<EmptyModel>() {
|
||||||
|
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||||
|
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.name)
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemView.isEnabled = false
|
||||||
|
itemView.isFocusable = false
|
||||||
|
itemView.isClickable = false
|
||||||
|
itemView.visibility = View.INVISIBLE
|
||||||
|
|
||||||
|
name.text = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(model: EmptyModel) = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
class Model(
|
||||||
|
val badge: Badge,
|
||||||
|
val isSelected: Boolean = false,
|
||||||
|
val isFaded: Boolean = false
|
||||||
|
) : PreferenceModel<Model>() {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||||
|
return newItem.badge.id == badge.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||||
|
return super.areContentsTheSame(newItem) &&
|
||||||
|
badge == newItem.badge &&
|
||||||
|
isSelected == newItem.isSelected &&
|
||||||
|
isFaded == newItem.isFaded
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(newItem: Model): Any? {
|
||||||
|
return if (badge == newItem.badge && isSelected != newItem.isSelected) {
|
||||||
|
SELECTION_CHANGED
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(itemView: View, private val onBadgeClicked: OnBadgeClicked) : MappingViewHolder<Model>(itemView) {
|
||||||
|
|
||||||
|
private val check: ImageView = itemView.findViewById(R.id.checkmark)
|
||||||
|
private val badge: ImageView = itemView.findViewById(R.id.badge)
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.name)
|
||||||
|
|
||||||
|
private var checkAnimator: ObjectAnimator? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
check.isSelected = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun bind(model: Model) {
|
||||||
|
itemView.setOnClickListener {
|
||||||
|
onBadgeClicked(model.badge, model.isSelected, model.isFaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAnimator?.cancel()
|
||||||
|
if (payload.isNotEmpty()) {
|
||||||
|
checkAnimator = if (model.isSelected) {
|
||||||
|
ObjectAnimator.ofFloat(check, "alpha", 1f)
|
||||||
|
} else {
|
||||||
|
ObjectAnimator.ofFloat(check, "alpha", 0f)
|
||||||
|
}
|
||||||
|
checkAnimator?.start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.alpha = if (model.badge.isExpired() || model.isFaded) 0.5f else 1f
|
||||||
|
|
||||||
|
GlideApp.with(badge)
|
||||||
|
.load(model.badge)
|
||||||
|
.downsample(DownsampleStrategy.NONE)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.NONE)
|
||||||
|
.transform(
|
||||||
|
BadgeSpriteTransformation(BadgeSpriteTransformation.Size.BADGE_64, model.badge.imageDensity, ThemeUtil.isDarkTheme(context)),
|
||||||
|
)
|
||||||
|
.into(badge)
|
||||||
|
|
||||||
|
if (model.isSelected) {
|
||||||
|
check.alpha = 1f
|
||||||
|
} else {
|
||||||
|
check.alpha = 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
name.text = model.badge.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Category(val code: String) {
|
||||||
|
Donor("donor"),
|
||||||
|
Other("other"),
|
||||||
|
Testing("testing"); // Will be removed before final release
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromCode(code: String): Category {
|
||||||
|
return when (code) {
|
||||||
|
"donor" -> Donor
|
||||||
|
"testing" -> Testing
|
||||||
|
else -> Other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BOOST_BADGE_ID = "BOOST"
|
||||||
|
|
||||||
|
private val SELECTION_CHANGED = Any()
|
||||||
|
|
||||||
|
fun register(mappingAdapter: MappingAdapter, onBadgeClicked: OnBadgeClicked) {
|
||||||
|
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it, onBadgeClicked) }, R.layout.badge_preference_view))
|
||||||
|
mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.badge_preference_view))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.models
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
|
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||||
|
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||||
|
import org.thoughtcrime.securesms.recipients.Recipient
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
|
||||||
|
object BadgePreview {
|
||||||
|
|
||||||
|
fun register(mappingAdapter: MappingAdapter) {
|
||||||
|
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.featured_badge_preview_preference))
|
||||||
|
mappingAdapter.registerFactory(SubscriptionModel::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.subscription_flow_badge_preview_preference))
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class BadgeModel<T : BadgeModel<T>> : PreferenceModel<T>() {
|
||||||
|
abstract val badge: Badge?
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Model(override val badge: Badge?) : BadgeModel<Model>() {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||||
|
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(newItem: Model): Any? {
|
||||||
|
return Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SubscriptionModel(override val badge: Badge?) : BadgeModel<SubscriptionModel>() {
|
||||||
|
override fun areItemsTheSame(newItem: SubscriptionModel): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: SubscriptionModel): Boolean {
|
||||||
|
return super.areContentsTheSame(newItem) && badge == newItem.badge
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(newItem: SubscriptionModel): Any? {
|
||||||
|
return Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder<T : BadgeModel<T>>(itemView: View) : MappingViewHolder<T>(itemView) {
|
||||||
|
|
||||||
|
private val avatar: AvatarImageView = itemView.findViewById(R.id.avatar)
|
||||||
|
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||||
|
|
||||||
|
override fun bind(model: T) {
|
||||||
|
if (payload.isEmpty()) {
|
||||||
|
avatar.setRecipient(Recipient.self())
|
||||||
|
avatar.disableQuickContact()
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.setBadge(model.badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.models
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
|
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
|
||||||
|
object ExpiredBadge {
|
||||||
|
|
||||||
|
class Model(val badge: Badge) : PreferenceModel<Model>() {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||||
|
return newItem.badge.id == badge.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||||
|
return super.areContentsTheSame(newItem) && newItem.badge == badge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||||
|
|
||||||
|
private val badge: BadgeImageView = itemView.findViewById(R.id.expired_badge)
|
||||||
|
|
||||||
|
override fun bind(model: Model) {
|
||||||
|
badge.setBadge(model.badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun register(adapter: MappingAdapter) {
|
||||||
|
adapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.expired_badge_preference))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.models
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.TextView
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||||
|
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
|
||||||
|
|
||||||
|
data class LargeBadge(
|
||||||
|
val badge: Badge
|
||||||
|
) {
|
||||||
|
|
||||||
|
class Model(val largeBadge: LargeBadge, val shortName: String, val maxLines: Int) : MappingModel<Model> {
|
||||||
|
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||||
|
return newItem.largeBadge.badge.id == largeBadge.badge.id
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||||
|
return newItem.largeBadge == largeBadge && newItem.shortName == shortName && newItem.maxLines == maxLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyModel : MappingModel<EmptyModel> {
|
||||||
|
override fun areItemsTheSame(newItem: EmptyModel): Boolean = true
|
||||||
|
override fun areContentsTheSame(newItem: EmptyModel): Boolean = true
|
||||||
|
}
|
||||||
|
|
||||||
|
class EmptyViewHolder(itemView: View) : MappingViewHolder<EmptyModel>(itemView) {
|
||||||
|
override fun bind(model: EmptyModel) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||||
|
|
||||||
|
private val badge: BadgeImageView = itemView.findViewById(R.id.badge)
|
||||||
|
private val name: TextView = itemView.findViewById(R.id.name)
|
||||||
|
private val description: TextView = itemView.findViewById(R.id.description)
|
||||||
|
|
||||||
|
override fun bind(model: Model) {
|
||||||
|
badge.setBadge(model.largeBadge.badge)
|
||||||
|
|
||||||
|
name.text = model.largeBadge.badge.name
|
||||||
|
description.text = model.largeBadge.badge.resolveDescription(model.shortName)
|
||||||
|
description.setLines(model.maxLines)
|
||||||
|
description.maxLines = model.maxLines
|
||||||
|
description.minLines = model.maxLines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun register(mappingAdapter: MappingAdapter) {
|
||||||
|
mappingAdapter.registerFactory(Model::class.java, LayoutFactory({ ViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
|
||||||
|
mappingAdapter.registerFactory(EmptyModel::class.java, LayoutFactory({ EmptyViewHolder(it) }, R.layout.view_badge_bottom_sheet_dialog_fragment_page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.self.expired
|
||||||
|
|
||||||
|
import androidx.fragment.app.FragmentManager
|
||||||
|
import org.signal.core.util.DimensionUnit
|
||||||
|
import org.thoughtcrime.securesms.R
|
||||||
|
import org.thoughtcrime.securesms.badges.models.Badge
|
||||||
|
import org.thoughtcrime.securesms.badges.models.ExpiredBadge
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||||
|
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||||
|
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||||
|
import org.thoughtcrime.securesms.components.settings.configure
|
||||||
|
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||||
|
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet displaying a fading badge with a notice and action for becoming a subscriber again.
|
||||||
|
*/
|
||||||
|
class ExpiredBadgeBottomSheetDialogFragment : DSLSettingsBottomSheetFragment(
|
||||||
|
peekHeightPercentage = 1f
|
||||||
|
) {
|
||||||
|
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||||
|
ExpiredBadge.register(adapter)
|
||||||
|
|
||||||
|
adapter.submitList(getConfiguration().toMappingModelList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getConfiguration(): DSLConfiguration {
|
||||||
|
val badge: Badge = ExpiredBadgeBottomSheetDialogFragmentArgs.fromBundle(requireArguments()).badge
|
||||||
|
val isLikelyASustainer = SignalStore.donationsValues().isLikelyASustainer()
|
||||||
|
|
||||||
|
return configure {
|
||||||
|
customPref(ExpiredBadge.Model(badge))
|
||||||
|
|
||||||
|
sectionHeaderPref(
|
||||||
|
DSLSettingsText.from(
|
||||||
|
if (badge.isBoost()) {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__your_badge_has_expired
|
||||||
|
} else {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__subscription_cancelled
|
||||||
|
},
|
||||||
|
DSLSettingsText.CenterModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
space(DimensionUnit.DP.toPixels(4f).toInt())
|
||||||
|
|
||||||
|
noPadTextPref(
|
||||||
|
DSLSettingsText.from(
|
||||||
|
if (badge.isBoost()) {
|
||||||
|
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_boost_badge_has_expired)
|
||||||
|
} else {
|
||||||
|
getString(R.string.ExpiredBadgeBottomSheetDialogFragment__your_sustainer, badge.name)
|
||||||
|
},
|
||||||
|
DSLSettingsText.CenterModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||||
|
|
||||||
|
noPadTextPref(
|
||||||
|
DSLSettingsText.from(
|
||||||
|
if (badge.isBoost()) {
|
||||||
|
if (isLikelyASustainer) {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can_reactivate
|
||||||
|
} else {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__to_continue_supporting_technology
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__you_can
|
||||||
|
},
|
||||||
|
DSLSettingsText.CenterModifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
space(DimensionUnit.DP.toPixels(92f).toInt())
|
||||||
|
|
||||||
|
primaryButton(
|
||||||
|
text = DSLSettingsText.from(
|
||||||
|
if (badge.isBoost()) {
|
||||||
|
if (isLikelyASustainer) {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__add_a_boost
|
||||||
|
} else {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__become_a_sustainer
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
R.string.ExpiredBadgeBottomSheetDialogFragment__renew_subscription
|
||||||
|
}
|
||||||
|
),
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
if (isLikelyASustainer) {
|
||||||
|
requireActivity().startActivity(AppSettingsActivity.boost(requireContext()))
|
||||||
|
} else {
|
||||||
|
requireActivity().startActivity(AppSettingsActivity.subscriptions(requireContext()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
secondaryButtonNoOutline(
|
||||||
|
text = DSLSettingsText.from(R.string.ExpiredBadgeBottomSheetDialogFragment__not_now),
|
||||||
|
onClick = {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@JvmStatic
|
||||||
|
fun show(badge: Badge, fragmentManager: FragmentManager) {
|
||||||
|
val args = ExpiredBadgeBottomSheetDialogFragmentArgs.Builder(badge).build()
|
||||||
|
val fragment = ExpiredBadgeBottomSheetDialogFragment()
|
||||||
|
fragment.arguments = args.toBundle()
|
||||||
|
|
||||||
|
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.thoughtcrime.securesms.badges.self.featured
|
||||||
|
|
||||||
|
enum class SelectFeaturedBadgeEvent {
|
||||||
|
NO_BADGE_SELECTED,
|
||||||
|
FAILED_TO_UPDATE_PROFILE,
|
||||||
|
SAVE_SUCCESSFUL
|
||||||
|
}
|
||||||