mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2024-12-17 17:20:21 +01:00
Compare commits
897 Commits
v4.2.2
...
docs-polish
| Author | SHA1 | Date | |
|---|---|---|---|
| bcce6a18a1 | |||
| 3bce760e05 | |||
| a7c60f0721 | |||
| 94ad68155f | |||
| cb2b5ff97b | |||
| 313c40590f | |||
| d806efc347 | |||
| 743db49215 | |||
| 4fdffdb8aa | |||
| 1699aae906 | |||
| 82eba49b95 | |||
| 218b1a8b7e | |||
| 6c1bba55b4 | |||
| c9fc1547b0 | |||
| 8a3a8455d4 | |||
| 9c437f6204 | |||
| ef698e58d4 | |||
| 01c0ac3d13 | |||
| ed7089902c | |||
| 1ddd4193a0 | |||
| 932875fd9d | |||
| 1899c84ee2 | |||
| 91e03a016f | |||
| a978c12ca3 | |||
| da1f2d9ed6 | |||
| 1ed6179782 | |||
| e15251e354 | |||
| c348dd765f | |||
| 78a23e6b02 | |||
| 590499684d | |||
| 59c50f8088 | |||
| ee4c759706 | |||
| d5bd3a7d68 | |||
| 7159195cb9 | |||
| 45d3855d84 | |||
| 650a6f0758 | |||
| ae6ba0cfb5 | |||
| d9a2317f82 | |||
| 3d8b521c0c | |||
| 1fd5bbbc4f | |||
| 62299cbf0b | |||
| 3eb969d3c5 | |||
| 85dba0f36b | |||
| cb6490ed59 | |||
| 315d830357 | |||
| 00caeae914 | |||
| 607f77d432 | |||
| c254b33753 | |||
| 59f3848056 | |||
| 69aa002c83 | |||
| 8630d1ab12 | |||
| 591aa9eaa5 | |||
| f82fa42cba | |||
| aa441c88db | |||
| 17ee22da72 | |||
| a9957fb46d | |||
| f5c87fdd4c | |||
| 23467a2248 | |||
| 9fa93e40cb | |||
| f4b852d2dd | |||
| f698b7fa9f | |||
| b21afa648d | |||
| b9575cc869 | |||
| 3ea91bc4ad | |||
| b3dce5fdb0 | |||
| 28ad8b8cd5 | |||
| 37d4ef770c | |||
| ba24e40512 | |||
| 8097593f5e | |||
| 23ccd69b5e | |||
| 5e0d6d77b9 | |||
| a06393f520 | |||
| 549ffcefc0 | |||
| c8721e8000 | |||
| 03882367da | |||
| 4d700d113d | |||
| 17bdd2a1d2 | |||
| 80a68012a2 | |||
| 3742906f75 | |||
| ae90edcdb5 | |||
| 9e76aadb0f | |||
| 043544d7ec | |||
| 2bf7d1dddc | |||
| e1741118ce | |||
| 58fb3f7f2d | |||
| 48e92a7e9b | |||
| 5bf16da09d | |||
| 23ca3dd665 | |||
| 2caff25fa2 | |||
| 37f835be8c | |||
| a6c1f2f776 | |||
| acf0dfc38c | |||
| 67f13831ce | |||
| d50e65af20 | |||
| b8d74b74a6 | |||
| 802a28d759 | |||
| c85d43b349 | |||
| 64ad491197 | |||
| ddd8e719ef | |||
| e51f7a51cc | |||
| e66f5d488b | |||
| b386cc83a7 | |||
| 09481e09b6 | |||
| 6913bf1a46 | |||
| e6c87ff00b | |||
| 5f41cf3c52 | |||
| 9edbe2e589 | |||
| b6876712f0 | |||
| 5b4c183466 | |||
| 393238f47c | |||
| aea526f69d | |||
| 13cf1cc004 | |||
| 1766a8bb2c | |||
| 9a5732ab70 | |||
| 2923ca1356 | |||
| 96f0083384 | |||
| febfc396e3 | |||
| 29735a807b | |||
| eb36f64676 | |||
| 4e01970d69 | |||
| e5713087e3 | |||
| b40fc7ff18 | |||
| 06ad2b7972 | |||
| 14eeb4451c | |||
| ead67adeb8 | |||
| c2d70a5107 | |||
| 90dffc975a | |||
| e5ba7caddc | |||
| ec6cee1389 | |||
| 1d16a19373 | |||
| 04c90b4f07 | |||
| 747cbb8e09 | |||
| 834487e548 | |||
| e1b6b54209 | |||
| 6366d60e4c | |||
| fabea36682 | |||
| 991332a809 | |||
| 85537a99e8 | |||
| 717c90c58b | |||
| ccb52e0acd | |||
| d84b30c071 | |||
| 5ae0afe1fe | |||
| d250e503b0 | |||
| afb0ae3d03 | |||
| 1a3f0bed47 | |||
| 1e280fb7e1 | |||
| 6feac55380 | |||
| 93d8eac037 | |||
| a11f99be3d | |||
| da17639309 | |||
| 10301aa742 | |||
| 2148d50393 | |||
| 12fb2a4ec5 | |||
| 8085e5b85c | |||
| 4ba310ec16 | |||
| 865c56f400 | |||
| c510e73256 | |||
| 73638129bc | |||
| 1a7a79d504 | |||
| d3268e4a72 | |||
| 15a93014a5 | |||
| e7735329bc | |||
| 3e222c68eb | |||
| a596bd9011 | |||
| 21a49710a8 | |||
| ce6fdb2558 | |||
| 0baee848a6 | |||
| 6f304bc9e6 | |||
| 1ca0dfffa0 | |||
| 1420084f5e | |||
| 3e03474437 | |||
| 95366e4d1b | |||
| df1775a1e6 | |||
| 30ccea18ab | |||
| 4cd90d74ad | |||
| 7f1214688a | |||
| aa89d0d156 | |||
| 787cd94362 | |||
| ec61a7fc51 | |||
| 9b627534f5 | |||
| 400a3707b2 | |||
| 38a5676b37 | |||
| f00cfa9108 | |||
| afa24722b2 | |||
| ea5cec53bc | |||
| 61dc649c70 | |||
| b20824e16c | |||
| 39330fdce3 | |||
| 6b725a8ef7 | |||
| cbcdeb2b43 | |||
| 4351610eca | |||
| 935d826a21 | |||
| a8c8137ade | |||
| 7b33e1fba8 | |||
| ade7bba97b | |||
| 08dd321311 | |||
| 67961180c9 | |||
| 1093892ede | |||
| ef05fa4905 | |||
| 6a99a4f1ae | |||
| 4895f396a2 | |||
| c3dffc5909 | |||
| af73e5993d | |||
| dfec1cbb02 | |||
| 8d8f7b2524 | |||
| db7b81587f | |||
| 31bb744ba7 | |||
| f4b349162f | |||
| 7f8adf8f03 | |||
| 1f4bb8a224 | |||
| 0c3dc8a6e9 | |||
| 1f15cf54af | |||
| c96ba30018 | |||
| ffea61917d | |||
| f5c11b8faf | |||
| 48b4b23204 | |||
| 9df78c8ac8 | |||
| 450bfe2e17 | |||
| c79eb36a7a | |||
| a58b3a42db | |||
| e344958224 | |||
| 05c4a6c682 | |||
| b2aec6d6a7 | |||
| 09c4609b6c | |||
| f6d2aa7aae | |||
| 92facf58f7 | |||
| 15c36c5a84 | |||
| cea0543e98 | |||
| c0bbe77788 | |||
| 9f8cbe140d | |||
| d0cf550b51 | |||
| a86725480f | |||
| 9ad22e1f6d | |||
| a266bcbae7 | |||
| 1ba210fe77 | |||
| 7898395359 | |||
| aeb732c2f6 | |||
| b665dce383 | |||
| f349f124d8 | |||
| 8212d7a00e | |||
| 36bcb1b26e | |||
| 8d6fe2be99 | |||
| d9571c70f2 | |||
| babbca48f8 | |||
| 8c4e2e57f9 | |||
| 8308651f44 | |||
| ad881bd46a | |||
| c037eb2d24 | |||
| 8b4353deba | |||
| a2be0d35fb | |||
| 1909bdc702 | |||
| 63909defaf | |||
| 8544984b17 | |||
| 563984c0df | |||
| 6500ee5fc9 | |||
| 2a9c138a23 | |||
| 61d8fa02d4 | |||
| 0fac2187f0 | |||
| 1db61be860 | |||
| 06a0dbd0f2 | |||
| 47c6daf0cc | |||
| bcdf502ce6 | |||
| f9e2f4931a | |||
| 8929d15df0 | |||
| 60d6a47bdb | |||
| 2f529535b1 | |||
| 90c9eae283 | |||
| added6105b | |||
| 2df39b5e24 | |||
| 3477dd9400 | |||
| 5282551f3d | |||
| dcadaaf29b | |||
| e9d6baa6ba | |||
| 6ae052a7a1 | |||
| 9f750c2516 | |||
| 1fe46834f2 | |||
| 3db85c7d37 | |||
| 1e4f1b8891 | |||
| 0fcd6d40ee | |||
| aaa4ffe789 | |||
| 8c4720d0cb | |||
| 94b97f6097 | |||
| 85800d96c8 | |||
| 3b4c06111a | |||
| 548d695a82 | |||
| c986a4b88b | |||
| 09940ccf8d | |||
| cfc323862f | |||
| d8cc867099 | |||
| dce8a1dff9 | |||
| 5bc9e014c2 | |||
| b0c9ba05f3 | |||
| 8a2876fca9 | |||
| 00d5f3b769 | |||
| 17f39ec6a0 | |||
| 498ffe8a71 | |||
| 858f0f2650 | |||
| 66ea2d5d71 | |||
| 3dd5b5d835 | |||
| 9127b1bbc6 | |||
| 1657bcf97d | |||
| 428db2f8f4 | |||
| a8fa9b5e58 | |||
| 4cc59d9fbd | |||
| c50306709b | |||
| 37d0bc2f14 | |||
| 2fda0454e3 | |||
| be19af62d9 | |||
| f915eb2a20 | |||
| 2bc1236a2c | |||
| 9db1924268 | |||
| 7a9f220290 | |||
| 8145387f77 | |||
| 063440e735 | |||
| 6724de09c9 | |||
| f379fd0f8c | |||
| 34a3387830 | |||
| 809cc1f234 | |||
| 12200a74ff | |||
| 2643ea9066 | |||
| 840df89ca6 | |||
| 0404b020e6 | |||
| fda41817e9 | |||
| f48cabcc0a | |||
| f123a905d5 | |||
| 9b7fbfd513 | |||
| e21b4d4236 | |||
| 9ec6911005 | |||
| bfc178faf3 | |||
| c4c93f881d | |||
| d7f9e17bcb | |||
| 13706c4f64 | |||
| f2f4b26c35 | |||
| 1b7b9da07c | |||
| 2817306659 | |||
| 5f587c2104 | |||
| f5670c39da | |||
| c0105d135c | |||
| f1a9814faa | |||
| 8f0e7512be | |||
| 7d49209f79 | |||
| b2e285c2c7 | |||
| 54020250dc | |||
| 01acbb970a | |||
| 36cad35d46 | |||
| 41c9c84139 | |||
| 9e9ca521b0 | |||
| 1927862871 | |||
| c80eaf8550 | |||
| 9e6a35bac3 | |||
| 62ffcf94a6 | |||
| 2b77920dcd | |||
| 38b7e4d4a4 | |||
| 3e4d4f04ef | |||
| f6f3d17383 | |||
| d5f6b76ec5 | |||
| ae75be56ea | |||
| 50b90c4814 | |||
| 6eddb5f30f | |||
| cee8f3a4b6 | |||
| a2e5733be6 | |||
| 5075657c02 | |||
| 0450b1ace2 | |||
| 0ebf1da05d | |||
| 07ad3edbc2 | |||
| b40ed2a7f3 | |||
| 29b074607c | |||
| 258a157e44 | |||
| 92d9a1f9f2 | |||
| e5009a58df | |||
| 35a1ce4903 | |||
| 7c4c627ee9 | |||
| b7575ec01d | |||
| a0351d6f99 | |||
| 6f68840b3a | |||
| 2eef858db1 | |||
| 434315fb08 | |||
| 9b495e576c | |||
| 5405f44f54 | |||
| 53b05974c9 | |||
| 3530022152 | |||
| dc6bab7ae6 | |||
| c9b4ce457e | |||
| bd3325a889 | |||
| 9e347484eb | |||
| 894af0602d | |||
| aa6011a196 | |||
| f24035a99d | |||
| c006b8150f | |||
| 9e4499de6d | |||
| a018e4a581 | |||
| d29fd93ea7 | |||
| b30c7af3a3 | |||
| 2798671d22 | |||
| 0339b399f7 | |||
| d048962959 | |||
| 4af91c4cae | |||
| 8a445ece90 | |||
| 5082f5b4a4 | |||
| c8fae0ec43 | |||
| d9a8d333f7 | |||
| 73f8c543e3 | |||
| 14945a9296 | |||
| 155ffd16ec | |||
| 1eb1e52912 | |||
| af173ee5c4 | |||
| 06a2f7e4da | |||
| 958299784d | |||
| 49b6979ff0 | |||
| 3c493db613 | |||
| 86cc85b3a5 | |||
| 0427d2e578 | |||
| c90d911d2a | |||
| 2473d14baa | |||
| a36f2147d8 | |||
| 3837e92556 | |||
| 76505afff2 | |||
| 89c9a01b20 | |||
| 68517cf852 | |||
| fbbad55a0f | |||
| bcca27bfdb | |||
| c55a7692c5 | |||
| a8aa829e4c | |||
| d5af03ce18 | |||
| 101ef7a81a | |||
| 71daeed81a | |||
| d44324eb4d | |||
| 93d2ef66cf | |||
| 8a78943e94 | |||
| d0f0013755 | |||
| f22ee1a6cf | |||
| 4a58ca60ac | |||
| b206868730 | |||
| bd4c10b224 | |||
| 46d15d1811 | |||
| ea64be55e1 | |||
| c2cd58f63d | |||
| f21fc76ced | |||
| 13bd51b97d | |||
| a1ed0a84b8 | |||
| 4815e447fa | |||
| d80cad57b6 | |||
| a58be6ebb6 | |||
| dfa0272065 | |||
| 9723c47b25 | |||
| 3eb51eca58 | |||
| 86151d4ec2 | |||
| 717d05c4a3 | |||
| 3c43c5d254 | |||
| 0bae260fae | |||
| e694848cd5 | |||
| f5f61c5806 | |||
| 96c1c1d439 | |||
| 3e560278b6 | |||
| 6e131e0bad | |||
| bd158f3b0d | |||
| a96fb2f8d1 | |||
| 148261a1ee | |||
| 22b7aa90b2 | |||
| 88d9e70ef8 | |||
| a0cfc19063 | |||
| 451aab46dc | |||
| 3b3db562cd | |||
| 15ed6ea4a6 | |||
| 9f661ff4e2 | |||
| 52c39c9641 | |||
| 0dab486ac8 | |||
| d640f4a5d5 | |||
| 1c47bfbf44 | |||
| db3fc4ee7b | |||
| 74df35d3b0 | |||
| c01c629f73 | |||
| 25e4a1e86d | |||
| e27013071b | |||
| 2679bc2e94 | |||
| 93ab713748 | |||
| bc1d86e303 | |||
| b386346cf1 | |||
| b6db41dd50 | |||
| 6bca013e67 | |||
| e538826cd8 | |||
| a7a56ea1d9 | |||
| 77d0f70270 | |||
| 2a20f78877 | |||
| b027708828 | |||
| a0bf298b66 | |||
| e3b22d83ad | |||
| ab4e4e1db9 | |||
| a393bc8163 | |||
| 3bc4fd222c | |||
| 1b01dcec6d | |||
| 1d5c361b9a | |||
| 4cd396a0d2 | |||
| bcc80be8e9 | |||
| 5d12217eab | |||
| 4b0046a60b | |||
| 114b76e3f8 | |||
| c72aa5d074 | |||
| 2e9882b0bd | |||
| 8ff8f9d695 | |||
| 1e3c2024bb | |||
| 8bec0004cc | |||
| c337a6888d | |||
| e72d4638d2 | |||
| 7dd4dc3b40 | |||
| 980c7a9ddd | |||
| cb5f26d354 | |||
| 04d886e546 | |||
| ed12ccaac2 | |||
| e9e9286fbb | |||
| f4ffa5237c | |||
| ef15dca0b4 | |||
| 396b3ae639 | |||
| c409f58067 | |||
| 69ca731641 | |||
| 006a30e65c | |||
| 0a9aa8b3d2 | |||
| 69196084fc | |||
| cf4105e256 | |||
| ad6aa10cd2 | |||
| ba29d0242e | |||
| ca64ed9784 | |||
| a227e21fcf | |||
| 7c9c358ce2 | |||
| 84237f79fc | |||
| 80e0bfb61f | |||
| 153f80fe64 | |||
| 19881703e5 | |||
| a668bd5736 | |||
| e8cab01c03 | |||
| 3f72633d22 | |||
| 5c7ad0926c | |||
| 7eca44bb84 | |||
| 2f39cfd86f | |||
| 2fdc23274d | |||
| 91a39cae23 | |||
| 882966d5d3 | |||
| 3ed5e6e50b | |||
| df6cec6a32 | |||
| 24c47657f4 | |||
| cf6afb7687 | |||
| 774af334fd | |||
| 9dc6c1327f | |||
| af414d7f6e | |||
| a040fa65bb | |||
| 9fc26ca799 | |||
| 9dad55ce8d | |||
| 6e0addbea3 | |||
| e452edb781 | |||
| a3283708e7 | |||
| e833d66557 | |||
| 71f5b51350 | |||
| 20cec4db11 | |||
| 2085dc5d60 | |||
| 9290fcc6b2 | |||
| 0c3d643408 | |||
| 6d5c3ae484 | |||
| cccdcef914 | |||
| e63e158b2d | |||
| 892b91e958 | |||
| fb04108b11 | |||
| 424328b9d1 | |||
| e73f5c40cf | |||
| 4c960bdc44 | |||
| dcb82951ed | |||
| 138cce4436 | |||
| 98417dafc4 | |||
| e8374be19c | |||
| 62a2f61751 | |||
| 7323bb4333 | |||
| 2d47175f94 | |||
| a6d7604d21 | |||
| 9e3573fc76 | |||
| 41e873d5ca | |||
| 13ebaf587e | |||
| 61e20550bc | |||
| d1cc5c1769 | |||
| 16b041c8c6 | |||
| 810f248c74 | |||
| 813fecddfe | |||
| 5a7d61c964 | |||
| ad1b091b18 | |||
| cba24983e6 | |||
| 4dc2a1b72d | |||
| 3d4e4e2ef9 | |||
| 113c67ec95 | |||
| a2e887024f | |||
| 37262b3ed5 | |||
| dca4fe7701 | |||
| 88c9334d18 | |||
| 58f06aa821 | |||
| ae5deab8d3 | |||
| bb0482104c | |||
| edfece3206 | |||
| c32cf8055d | |||
| 72ec03a822 | |||
| d89e0efedd | |||
| 707e8592d9 | |||
| fa9e0086f6 | |||
| a1b27e9a99 | |||
| f68d8fd97c | |||
| abff42a264 | |||
| c1ced70836 | |||
| e14966d36e | |||
| 97943fc609 | |||
| 0f143b2e77 | |||
| be10dcbcfc | |||
| 97dbec927c | |||
| b4879ca2a3 | |||
| 15884c0169 | |||
| 9f2d5486b6 | |||
| 80f0108b41 | |||
| c37a7ebfe7 | |||
| 02c2c65d41 | |||
| 7c4700b238 | |||
| 54190ffff9 | |||
| 17eed9662e | |||
| bb116bccb4 | |||
| 6cc267689e | |||
| 0e6909845f | |||
| 96ad9faa85 | |||
| 768c497025 | |||
| 3ec29d8ef4 | |||
| 6c4b92531f | |||
| 46d6159da5 | |||
| aab6e1c52f | |||
| c0a01318b5 | |||
| 13090ff6ed | |||
| 90a20cd52f | |||
| 74245d3f2b | |||
| e48452ccff | |||
| 39370ba1ef | |||
| a02cfb4f41 | |||
| 4370012b8a | |||
| 20c33aea72 | |||
| c11a1aa0e6 | |||
| 166b789f3c | |||
| bbc26e272c | |||
| 6c839f8075 | |||
| be91f97c83 | |||
| e085cb7350 | |||
| 12574bed96 | |||
| 2137893111 | |||
| e552a28a4d | |||
| b20031d875 | |||
| 558b3fa356 | |||
| cb337cef10 | |||
| cd63f81292 | |||
| 6205b03943 | |||
| 82924ce8c6 | |||
| b1067c339c | |||
| 0d6e4b48f6 | |||
| 84d2c408ce | |||
| de434b730e | |||
| 1251dbc4b0 | |||
| d115ad228b | |||
| 28d6f62b74 | |||
| 2b9238144b | |||
| a2e1b7ae0a | |||
| a00bb6d5ef | |||
| da12b651e4 | |||
| a936c14cf2 | |||
| e6aad24e5f | |||
| 8dac96f415 | |||
| aae0802ec8 | |||
| 74a20ef70c | |||
| a2a29628a7 | |||
| 0b046315ac | |||
| 372d7ffaa9 | |||
| ece928d57e | |||
| e1740a8be4 | |||
| 36eba01ef4 | |||
| 9e045a44db | |||
| b7d42ef889 | |||
| e55cd82ec3 | |||
| 34e08b2058 | |||
| 5e9b7366cc | |||
| 64fb1f0b85 | |||
| 84e43c57f6 | |||
| ffa37b1684 | |||
| 86271fe109 | |||
| 5dab099b5c | |||
| 199e61e5c6 | |||
| 76b4fd34c1 | |||
| b159496257 | |||
| c0fb29d5f7 | |||
| 4ab7e5e1c8 | |||
| 9e847c2e1f | |||
| d105e59655 | |||
| f128ebac87 | |||
| b4de9c266b | |||
| e410fc7736 | |||
| f5bd6eb4c3 | |||
| cee403c1ed | |||
| 8786e2147a | |||
| 6b8705e9f4 | |||
| acfb98bd81 | |||
| c240456b80 | |||
| 9e1641a154 | |||
| 17cd3cdca4 | |||
| aa264690ab | |||
| 0e837ae392 | |||
| 68525b4131 | |||
| 8775db7c97 | |||
| f266debd56 | |||
| 044c7a8191 | |||
| 677c6aeb2e | |||
| 7b8f5be821 | |||
| 21765905a7 | |||
| 70a9c01477 | |||
| 678dbec3e2 | |||
| bd4c7dffbf | |||
| 1eb4030080 | |||
| bcc64442e9 | |||
| 1246b9e376 | |||
| d6e9a87d58 | |||
| cddd3cd673 | |||
| e00ef7c7da | |||
| 1a201cfadf | |||
| a4ecb41743 | |||
| e347f5329c | |||
| 741b3e8848 | |||
| 7b4710d198 | |||
| c77f6100c5 | |||
| 138dc7fe8f | |||
| 0535d84719 | |||
| f4447ffe89 | |||
| 146d5f99bc | |||
| 73e5fff8f5 | |||
| 33e7538172 | |||
| 49c9c501aa | |||
| a177dc5a13 | |||
| a4f207875f | |||
| bcca0998d5 | |||
| 95cc9e1e55 | |||
| ab5ae2d2cb | |||
| 40a91a7273 | |||
| 1240b31df8 | |||
| ff14730738 | |||
| 0cba3a4bb3 | |||
| 208f8a3346 | |||
| caa3efb9ed | |||
| 4beb916754 | |||
| c1ee04eed1 | |||
| 0ad3bc9993 | |||
| 9893aa665a | |||
| fda8836ab8 | |||
| 05fdd07409 | |||
| fb8f5facd0 | |||
| 8bdb784a14 | |||
| 5d785aad2e | |||
| ce11d58a76 | |||
| 887b374bfc | |||
| 94dc967197 | |||
| 4319a581ca | |||
| fb05218558 | |||
| edf2d02a0d | |||
| 87ba429dfd | |||
| 7af1a7cf76 | |||
| df619acdd4 | |||
| 503d0cd451 | |||
| 1294a00ee7 | |||
| 0a8069ada2 | |||
| c167f594b9 | |||
| ce5124594d | |||
| 5de96aa7c4 | |||
| cdbf8e2715 | |||
| 69b2f8f535 | |||
| ff17f89551 | |||
| 358712fa31 | |||
| 75cad8a6bf | |||
| e5969e197a | |||
| a9ffe4e039 | |||
| bf2129c4ae | |||
| 04f10aede7 | |||
| ffbff93374 | |||
| f3630d934c | |||
| 6f59df4e33 | |||
| e44e9a0940 | |||
| c43ba7bf23 | |||
| 9e48e1f74a | |||
| 0001885971 | |||
| e0c932c04e | |||
| 01a86336c0 | |||
| 48d24d3582 | |||
| 07ef6e4090 | |||
| 19163776e3 | |||
| 62b1f786f1 | |||
| d479e9b2bf | |||
| 0beb260b00 | |||
| bc28568c63 | |||
| a4dd520248 | |||
| 9ad29aa17e | |||
| 6f24281671 | |||
| eb81b62892 | |||
| ef1133ee98 | |||
| 1872744543 | |||
| 303aeaaba5 | |||
| c5359d698c | |||
| acd72fb269 | |||
| 8d096f469d | |||
| b204d21d9e | |||
| c9620a594e | |||
| 538024de61 | |||
| 5c9a14fdb6 | |||
| 9295bdca3e | |||
| 00466f4654 | |||
| 5d976d3c67 | |||
| 7360bd098a | |||
| 3a755286c1 | |||
| e5f07993a7 | |||
| 56a3f98dc0 | |||
| 9949ac073f | |||
| c102a884d1 | |||
| 1e6e9ad5e2 | |||
| e6c5ad5833 | |||
| 8af0229f52 | |||
| 7f0355ec67 | |||
| ade8c97b16 | |||
| ee18bce964 | |||
| 7e204127b8 | |||
| 5619152810 | |||
| 6f463c16a5 | |||
| 33b3557950 | |||
| e5912e58f5 | |||
| 7a0d2add17 | |||
| ac30602a50 | |||
| 6cc4e2e801 | |||
| 5650898c2c | |||
| 336a170b3a | |||
| 9c06acd4bc | |||
| 1d819a4af3 | |||
| 5263698e64 | |||
| 5d0d9a1c18 | |||
| 7407884223 | |||
| 07acbfe743 | |||
| 5e2c868612 | |||
| f6ed099f17 | |||
| 098cbf33b6 | |||
| f8cf35879f | |||
| 60fedbf5d2 | |||
| 87d306383c | |||
| 18b772a80b | |||
| 789c54bd5f | |||
| f8214b0604 | |||
| 2eec81c35e | |||
| eb099c526a | |||
| e18bb74bfd | |||
| 9225f437e9 | |||
| de7548a9a8 | |||
| a58a0fae29 | |||
| e32b24ef70 | |||
| 5d73b364d8 | |||
| fa2f303547 | |||
| a6e4e68bc5 | |||
| 4485d46307 | |||
| a7345ee4d9 | |||
| 388aaec80b | |||
| 21722b3417 | |||
| e6e5faeb9c | |||
| 67d78e14be | |||
| 2b09fb425d | |||
| c7f70f0ed0 | |||
| 5806a2ceb4 | |||
| fb165622aa | |||
| 7e3d53b621 | |||
| 7544d2f9e7 | |||
| 4296b6c622 | |||
| b5652bce81 | |||
| b8298aa458 | |||
| c3244f1b76 | |||
| 0ad74d9538 | |||
| a4be68f4bd | |||
| 0cb8f8ad82 | |||
| 9d7bb06396 | |||
| a9c2a7dcaa | |||
| 38b28f866c | |||
| bfa7ff16ff | |||
| 5c2b70a214 | |||
| 7e3f91f87c | |||
| f54faebff3 | |||
| 4e5aa3dcbc | |||
| 56f3874a93 | |||
| 828b502431 | |||
| 491fe4a9bf | |||
| f8302e2030 | |||
| fd34c39552 | |||
| b1fa1a84fe | |||
| cf23399262 | |||
| b5a812769b | |||
| 40e1b01baf | |||
| 9c925ab040 | |||
| faceeb6fce | |||
| 07e8c1d76e | |||
| b1d8600215 | |||
| e14ab0fed0 | |||
| cb0c499f57 |
@@ -0,0 +1,68 @@
|
|||||||
|
name: Bug
|
||||||
|
description: File a bug report/issue
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["type:bug", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the bug you encountered.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Multiple selections are possible.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- Mac
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: OS version
|
||||||
|
description: Specify the OS version
|
||||||
|
placeholder: ex. Android 12, Ubuntu 20.04
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: Specify the SimpleX version
|
||||||
|
placeholder: ex. 4.3.2
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Current Behavior
|
||||||
|
description: A concise description of what you're experiencing.
|
||||||
|
placeholder: Bug happened!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: A concise description of what you expected to happen.
|
||||||
|
placeholder: No bug should happen!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. Go to ...
|
||||||
|
3. Click on ...
|
||||||
|
4. See error...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Relevant log output
|
||||||
|
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||||
|
render: shell
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Feature
|
||||||
|
description: Suggest your feature
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["type:enhancement", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: Is there an existing issue for this?
|
||||||
|
description: Please search to see if an issue already exists for the bug you encountered.
|
||||||
|
options:
|
||||||
|
- label: I have searched the existing issues
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Platform
|
||||||
|
description: Multiple selections are possible. If selected input is "all", this considered to be a general feature.
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Linux
|
||||||
|
- Mac
|
||||||
|
- Windows
|
||||||
|
- Android
|
||||||
|
- iOS
|
||||||
|
- all
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
attributes:
|
||||||
|
label: App version
|
||||||
|
description: Specify the SimpleX version
|
||||||
|
placeholder: ex. 4.3.2
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Feature
|
||||||
|
description: Describe the feature you would like to see added
|
||||||
|
placeholder: SimpleX Chat should make me coffee!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
name: Question
|
||||||
|
description: Ask your question
|
||||||
|
title: "[Q]: "
|
||||||
|
labels: ["type:question", "type:triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Generally, we encourage you to ask questions in our [official group](https://simplex.chat/invitation/#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3Dsimplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D), but you can do it anyway :)
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Question
|
||||||
|
description: Please ask your question in plain english.
|
||||||
|
placeholder: Is SimpleX - chat?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
- stable
|
- stable
|
||||||
- sqlcipher
|
- users
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -52,9 +52,9 @@ jobs:
|
|||||||
- os: ubuntu-20.04
|
- os: ubuntu-20.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
asset_name: simplex-chat-ubuntu-20_04-x86-64
|
||||||
- os: ubuntu-18.04
|
- os: ubuntu-22.04
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-ubuntu-18_04-x86-64
|
asset_name: simplex-chat-ubuntu-22_04-x86-64
|
||||||
- os: macos-latest
|
- os: macos-latest
|
||||||
cache_path: ~/.cabal/store
|
cache_path: ~/.cabal/store
|
||||||
asset_name: simplex-chat-macos-x86-64
|
asset_name: simplex-chat-macos-x86-64
|
||||||
@@ -91,8 +91,12 @@ jobs:
|
|||||||
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
echo " extra-lib-dirs: /usr/local/opt/openssl@1.1/lib" >> cabal.project.local
|
||||||
echo " flags: +openssl" >> cabal.project.local
|
echo " flags: +openssl" >> cabal.project.local
|
||||||
|
|
||||||
|
- name: Install pkg-config for Mac
|
||||||
|
if: matrix.os == 'macos-latest'
|
||||||
|
run: brew install pkg-config
|
||||||
|
|
||||||
- name: Unix prepare cabal.project.local for Ubuntu
|
- name: Unix prepare cabal.project.local for Ubuntu
|
||||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-18.04'
|
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "ignore-project: False" >> cabal.project.local
|
echo "ignore-project: False" >> cabal.project.local
|
||||||
@@ -108,8 +112,8 @@ jobs:
|
|||||||
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
echo "::set-output name=bin_path::$(cabal list-bin simplex-chat)"
|
||||||
|
|
||||||
- name: Unix test
|
- name: Unix test
|
||||||
if: matrix.os != 'windows-latest' && matrix.os != 'ubuntu-20.04'
|
if: matrix.os != 'windows-latest'
|
||||||
timeout-minutes: 10
|
timeout-minutes: 30
|
||||||
shell: bash
|
shell: bash
|
||||||
run: cabal test --test-show-details=direct
|
run: cabal test --test-show-details=direct
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -42,12 +42,15 @@ stack.yaml.lock
|
|||||||
|
|
||||||
# Temporary test files
|
# Temporary test files
|
||||||
tests/tmp
|
tests/tmp
|
||||||
|
tests/tmp*
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
*.devcontainer
|
||||||
# for website
|
# for website
|
||||||
website/node_modules/
|
website/node_modules/
|
||||||
website/src/blog/
|
website/src/blog/
|
||||||
|
website/translations.json
|
||||||
|
website/src/_data/supported_languages.json
|
||||||
website/src/img/images/
|
website/src/img/images/
|
||||||
website/src/images/
|
website/src/images/
|
||||||
# Generated files
|
# Generated files
|
||||||
@@ -72,3 +75,4 @@ website/package-lock.json
|
|||||||
# Ignore test files
|
# Ignore test files
|
||||||
website/.cache
|
website/.cache
|
||||||
website/test/stubs-layout-cache/_includes/*.js
|
website/test/stubs-layout-cache/_includes/*.js
|
||||||
|
apps/android/app/release
|
||||||
|
|||||||
+28
-6
@@ -1,10 +1,32 @@
|
|||||||
FROM haskell:8.10.4 AS build-stage
|
FROM ubuntu:focal AS build
|
||||||
# if you encounter "version `GLIBC_2.28' not found" error when running
|
|
||||||
# chat client executable, build with the following base image instead:
|
# Install curl and simplex-chat-related dependencies
|
||||||
# FROM haskell:8.10.4-stretch AS build-stage
|
RUN apt-get update && apt-get install -y curl git build-essential libgmp3-dev zlib1g-dev libssl-dev
|
||||||
|
|
||||||
|
# Install ghcup
|
||||||
|
RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/bin/ghcup && \
|
||||||
|
chmod +x /usr/bin/ghcup
|
||||||
|
|
||||||
|
# Install ghc
|
||||||
|
RUN ghcup install ghc 8.10.7
|
||||||
|
# Install cabal
|
||||||
|
RUN ghcup install cabal
|
||||||
|
# Set both as default
|
||||||
|
RUN ghcup set ghc 8.10.7 && \
|
||||||
|
ghcup set cabal
|
||||||
|
|
||||||
COPY . /project
|
COPY . /project
|
||||||
WORKDIR /project
|
WORKDIR /project
|
||||||
RUN stack install
|
|
||||||
|
# Adjust PATH
|
||||||
|
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
|
||||||
|
|
||||||
|
# Adjust build
|
||||||
|
RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
|
||||||
|
|
||||||
|
# Compile simplex-chat
|
||||||
|
RUN cabal update
|
||||||
|
RUN cabal install
|
||||||
|
|
||||||
FROM scratch AS export-stage
|
FROM scratch AS export-stage
|
||||||
COPY --from=build-stage /root/.local/bin/simplex-chat /
|
COPY --from=build /root/.cabal/bin/simplex-chat /
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
|
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
||||||
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
|
[](https://github.com/simplex-chat/simplex-chat/releases)
|
||||||
|
[](https://www.reddit.com/r/SimpleXChat)
|
||||||
|
[](https://mastodon.social/@simplex)
|
||||||
|
|
||||||
|
| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) |
|
||||||
|
|
||||||
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
|
||||||
|
|
||||||
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
|
||||||
|
|
||||||
[](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml)
|
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
|
||||||
[](https://github.com/simplex-chat/simplex-chat/releases)
|
## Welcome to SimpleX Chat!
|
||||||
[](https://twitter.com/SimpleXChat)
|
|
||||||
[](https://www.reddit.com/r/SimpleXChat)
|
1. 📲 [Install the app](#install-the-app).
|
||||||
|
2. ↔️ [Connect to the team](#connect-to-the-team-via-the-app) and [join user groups](#join-user-groups).
|
||||||
|
3. 🤝 [Make a private connection](#make-a-private-connection) with a friend.
|
||||||
|
4. 🔤 [Help translating SimpleX Chat](#help-translating-simplex-chat).
|
||||||
|
5. ⚡️ [Contribute](#contribute) and [help us with donations](#help-us-with-donations).
|
||||||
|
|
||||||
|
[Learn more about SimpleX Chat](#contents).
|
||||||
|
|
||||||
|
## Install the app
|
||||||
|
|
||||||
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
|
||||||
|
|
||||||
@@ -22,9 +38,109 @@
|
|||||||
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
- 🔐 Double ratchet end-to-end encryption, with additional encryption layer.
|
||||||
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084).
|
||||||
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
|
||||||
- 🖥 Available as a terminal (console) app / CLI on Linux, MacOS, Windows.
|
- 🖥 Available as a terminal (console) [app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows.
|
||||||
|
|
||||||
**NEW**: Security audit by [Trail of Bits](https://www.trailofbits.com/about), the [new website](https://simplex.chat) and v4.2 released! [See the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
## Connect to the team via the app
|
||||||
|
|
||||||
|
- to ask any questions
|
||||||
|
- to suggest any improvements
|
||||||
|
- to share anything relevant
|
||||||
|
|
||||||
|
## Join user groups
|
||||||
|
|
||||||
|
**Please note**: The groups below are created for the users to be able to ask questions, make suggestions and ask questions about SimpleX Chat only.
|
||||||
|
|
||||||
|
You also can:
|
||||||
|
- criticize the app, and make comparisons with other messengers.
|
||||||
|
- share new messengers you think could be interesting for privacy, as long as you don't spam.
|
||||||
|
- share some privacy related publications, infrequently.
|
||||||
|
- having preliminary approved with the admin in direct message, share the link to a group you created.
|
||||||
|
|
||||||
|
You must:
|
||||||
|
- be polite to other users
|
||||||
|
- avoid spam (too frequent messages, even if they are relevant)
|
||||||
|
- avoid any personal attacks or hostility.
|
||||||
|
- avoid sharing any content that is not relevant to the above (that includes, but is not limited to, discussing politics or any aspects of society other than privacy, security, technology and communications, sharing any content that may be found offensive by other users, etc.).
|
||||||
|
|
||||||
|
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||||
|
|
||||||
|
You can join an English-speaking users group if you want to ask any questions: [#SimpleX-Group-3](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fp-j-D_PrY2UMDchFHEUtbSES0nmzCnvD%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA3gBfMjB_GDEmKQwjNdqGbnX91yfuZ7nRJgQijsx5Khc%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%2262MvNZ_Ec2mmlS8V0QNtLQ%3D%3D%22%7D)
|
||||||
|
|
||||||
|
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||||
|
|
||||||
|
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||||
|
|
||||||
|
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||||
|
|
||||||
|
## Make a private connection
|
||||||
|
|
||||||
|
You need to share a link with your friend or scan a QR code from their phone, in person or during a video call, to make a connection and start messaging.
|
||||||
|
|
||||||
|
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
||||||
|
|
||||||
|
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app1.png" alt="Make a private connection" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app2.png" alt="Conversation" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/arrow.png" height="360"> <img src="https://github.com/simplex-chat/.github/blob/master/profile/images/app3.png" alt="Video call" height="360">
|
||||||
|
|
||||||
|
After you connect, you can [verify connection security code](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification).
|
||||||
|
|
||||||
|
## User guide (NEW)
|
||||||
|
|
||||||
|
Read about the app features and settings in the new [User guide](./docs/guide/README.md).
|
||||||
|
|
||||||
|
## Help translating SimpleX Chat
|
||||||
|
|
||||||
|
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
|
||||||
|
|
||||||
|
Join our translators to help SimpleX grow!
|
||||||
|
|
||||||
|
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||||
|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||||
|
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||||
|
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||||
|
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||||
|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||||
|
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||||
|
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||||
|
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||||
|
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||||
|
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||||
|
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||||
|
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||||
|
|
||||||
|
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||||
|
|
||||||
|
## Contribute
|
||||||
|
|
||||||
|
We would love to have you join the development! You can help us with:
|
||||||
|
|
||||||
|
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
||||||
|
- contributing to SimpleX Chat knowledge-base.
|
||||||
|
- developing features - please connect to us via chat so we can help you get started.
|
||||||
|
|
||||||
|
## Help us with donations
|
||||||
|
|
||||||
|
Huge thank you to everybody who donated to SimpleX Chat!
|
||||||
|
|
||||||
|
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||||
|
|
||||||
|
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||||
|
|
||||||
|
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
||||||
|
|
||||||
|
It is possible to donate via:
|
||||||
|
|
||||||
|
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
||||||
|
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
||||||
|
- Monero address: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
||||||
|
- Bitcoin address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- BCH address: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
||||||
|
- Ethereum address: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2
|
||||||
|
- Solana address: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L
|
||||||
|
|
||||||
|
Thank you,
|
||||||
|
|
||||||
|
Evgeny
|
||||||
|
|
||||||
|
SimpleX Chat founder
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
@@ -36,15 +152,11 @@
|
|||||||
- [Users own SimpleX network](#users-own-simplex-network)
|
- [Users own SimpleX network](#users-own-simplex-network)
|
||||||
- [Frequently asked questions](#frequently-asked-questions)
|
- [Frequently asked questions](#frequently-asked-questions)
|
||||||
- [News and updates](#news-and-updates)
|
- [News and updates](#news-and-updates)
|
||||||
- [Make a private connection](#make-a-private-connection)
|
|
||||||
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
- [Quick installation of a terminal app](#zap-quick-installation-of-a-terminal-app)
|
||||||
- [SimpleX Platform design](#simplex-platform-design)
|
- [SimpleX Platform design](#simplex-platform-design)
|
||||||
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
- [Privacy: technical details and limitations](#privacy-technical-details-and-limitations)
|
||||||
- [For developers](#for-developers)
|
- [For developers](#for-developers)
|
||||||
- [Roadmap](#roadmap)
|
- [Roadmap](#roadmap)
|
||||||
- [Join a user group](#join-a-user-group)
|
|
||||||
- [Contribute](#contribute)
|
|
||||||
- [Help us with donations](#help-us-with-donations)
|
|
||||||
- [Disclaimers, Security contact, License](#disclaimers)
|
- [Disclaimers, Security contact, License](#disclaimers)
|
||||||
|
|
||||||
## Why privacy matters
|
## Why privacy matters
|
||||||
@@ -75,36 +187,34 @@ You can use SimpleX with your own servers and still communicate with people usin
|
|||||||
|
|
||||||
## Frequently asked questions
|
## Frequently asked questions
|
||||||
|
|
||||||
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release annoucement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
1. _How SimpleX can deliver messages without any user identifiers?_ See [v2 release announcement](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) explaining how SimpleX works.
|
||||||
|
|
||||||
2. _Why should I not just use Signal?_ Signal is a centralised platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
2. _Why should I not just use Signal?_ Signal is a centralized platform that uses phone numbers to identify its users and their contacts. It means that while the content of your messages on Signal is protected with robust end-to-end encryption, there is a large amount of meta-data visible to Signal - who you talk with and when.
|
||||||
|
|
||||||
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identites?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
3. _How is it different from Matrix, Session, Ricochet, Cwtch, etc., that also don't require user identities?_ Although these platforms do not require a _real identity_, they do rely on anonymous user identities to deliver messages – it can be, for example, an identity key or a random number. Using a persistent user identity, even anonymous, creates a risk that user's connection graph becomes known to the observers and/or service providers, and it can lead to de-anonymizing some users. If the same user profile is used to connect to two different people via any messenger other than SimpleX, these two people can confirm if they are connected to the same person - they would use the same user identifier in the messages. With SimpleX there is no meta-data in common between your conversations with different contacts - the quality that no other messaging platform has.
|
||||||
|
|
||||||
## News and updates
|
## News and updates
|
||||||
|
|
||||||
Recent updates:
|
Recent updates:
|
||||||
|
|
||||||
[Nov 08, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md)
|
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||||
|
|
||||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md)
|
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||||
|
|
||||||
[Sep 1, 2022. v3.2: incognito mode, support .onion server hostnames, setting contact names, changing color scheme, etc. Implementation audit is arranged for October!](./blog/20220901-simplex-chat-v3.2-incognito-mode.md)
|
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
|
|
||||||
[Aug 8, 2022. v3.1: secret chat groups, access via Tor, reduced battery and traffic usage, advanced network settings, etc.](./blog/20220808-simplex-chat-v3.1-chat-groups.md)
|
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||||
|
|
||||||
[Jul 11, 2022. v3.0: instant push notifications for iOS, e2e encrypted WebRTC audio/video calls, chat database export/import, privacy and performance improvements](./blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md)
|
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||||
|
|
||||||
|
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||||
|
|
||||||
|
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||||
|
|
||||||
|
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||||
|
|
||||||
[All updates](./blog)
|
[All updates](./blog)
|
||||||
|
|
||||||
## Make a private connection
|
|
||||||
|
|
||||||
You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging.
|
|
||||||
|
|
||||||
The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established.
|
|
||||||
|
|
||||||
<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/conversation.png" alt="Make a private connection" width="594" height="360">
|
|
||||||
|
|
||||||
## :zap: Quick installation of a terminal app
|
## :zap: Quick installation of a terminal app
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -137,7 +247,7 @@ SimpleX Chat is a work in progress – we are releasing improvements as they are
|
|||||||
|
|
||||||
What is already implemented:
|
What is already implemented:
|
||||||
|
|
||||||
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notificaitons on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
1. Instead of user profile identifiers used by all other platforms, even the most private ones, SimpleX uses pairwise per-queue identifiers (2 addresses for each unidirectional message queue, with an optional 3rd address for push notifications on iOS, 2 queues in each connection between the users). It makes observing the network graph on the application level more difficult, as for `n` users there can be up to `n * (n-1)` message queues.
|
||||||
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
2. End-to-end encryption in each message queue using [NaCl cryptobox](https://nacl.cr.yp.to/box.html). This is added to allow redundancy in the future (passing each message via several servers), to avoid having the same ciphertext in different queues (that would only be visible to the attacker if TLS is compromised). The encryption keys used for this encryption are not rotated, instead we are planning to rotate the queues. Curve25519 keys are used for key negotiation.
|
||||||
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
3. [Double ratchet](https://signal.org/docs/specifications/doubleratchet/) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with forward secrecy (each message is encrypted by its own ephemeral key), break-in recovery (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial key agreement, initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||||
@@ -147,10 +257,11 @@ What is already implemented:
|
|||||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||||
|
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||||
|
|
||||||
We plan to add soon:
|
We plan to add soon:
|
||||||
|
|
||||||
1. Message queue rotation. Currently the queues created between two users are used until the contact is deleted, providing a long-term pairwise identifiers of the conversation. We are planning to add queue rotation to make these identifiers termporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
1. Automatic message queue rotation. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||||
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
2. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`.
|
||||||
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
3. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||||
|
|
||||||
@@ -160,7 +271,7 @@ You can:
|
|||||||
|
|
||||||
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
|
- use SimpleX Chat library to integrate chat functionality into your mobile apps.
|
||||||
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
|
- create chat bots and services in Haskell - see [simple](./apps/simplex-bot/) and more [advanced chat bot example](./apps/simplex-bot-advanced/).
|
||||||
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScipt chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
- create chat bots and services in any language running SimpleX Chat terminal CLI as a local WebSocket server. See [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) and [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js).
|
||||||
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
|
- run [simplex-chat terminal CLI](./docs/CLI.md) to execute individual chat commands, e.g. to send messages as part of shell script execution.
|
||||||
|
|
||||||
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
If you are considering developing with SimpleX platform please get in touch for any advice and support.
|
||||||
@@ -187,21 +298,35 @@ If you are considering developing with SimpleX platform please get in touch for
|
|||||||
- ✅ Chat database encryption.
|
- ✅ Chat database encryption.
|
||||||
- ✅ Automatic chat history deletion.
|
- ✅ Automatic chat history deletion.
|
||||||
- ✅ Links to join groups and improve groups stability.
|
- ✅ Links to join groups and improve groups stability.
|
||||||
|
- ✅ Voice messages (with recipient opt-out per contact).
|
||||||
|
- ✅ Basic authentication for SMP servers (to authorize creating new queues).
|
||||||
|
- ✅ View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
||||||
|
- ✅ Block screenshots and view in recent apps.
|
||||||
|
- ✅ Advanced server configuration.
|
||||||
|
- ✅ Disappearing messages (with recipient opt-in per-contact).
|
||||||
|
- ✅ "Live" messages.
|
||||||
|
- ✅ Contact verification via a separate out-of-band channel.
|
||||||
|
- ✅ Multiple user profiles in the same chat database.
|
||||||
|
- ✅ Optionally avoid re-using the same TCP session for multiple connections.
|
||||||
|
- ✅ Preserve message drafts.
|
||||||
|
- ✅ File server to optimize for efficient and private sending of large files.
|
||||||
|
- ✅ Improved audio & video calls.
|
||||||
|
- ✅ Support older Android OS and 32-bit CPUs.
|
||||||
|
- ✅ Hidden chat profiles.
|
||||||
|
- ✅ Sending and receiving large files via [XFTP protocol](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||||
|
- ✅ Video messages.
|
||||||
|
- ✅ App access passcode.
|
||||||
|
- 🏗 Improved Android app UI design.
|
||||||
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
- 🏗 SMP queue redundancy and rotation (manual is supported).
|
||||||
- 🏗 Voice messages (with recipient opt-out per contact).
|
- 🏗 Reduced battery and traffic usage in large groups.
|
||||||
- 🏗 Basic authentication for SMP servers (to authorize creating new queues).
|
- Include optional message into connection request sent via contact address.
|
||||||
- View deleted messages, full message deletion by sender (with recipient opt-in per contact).
|
|
||||||
- Block screenshots and view in recent apps.
|
|
||||||
- Optionally avoid re-using the same TCP session for multiple connections.
|
|
||||||
- Access password/pin (with optional alternative access password).
|
|
||||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||||
- Media server to optimize sending large files to groups.
|
- Optional alternative access password.
|
||||||
- Video messages.
|
- Local app files encryption.
|
||||||
|
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||||
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
- Message delivery confirmation (with sender opt-in or opt-out per contact, TBC).
|
||||||
- Multiple user profiles in the same chat database.
|
- Privately share your location.
|
||||||
- Advanced server configuration.
|
|
||||||
- Feeds/broadcasts.
|
- Feeds/broadcasts.
|
||||||
- Unconfirmed: disappearing messages (with recipient opt-in per-contact).
|
|
||||||
- Web widgets for custom interactivity in the chats.
|
- Web widgets for custom interactivity in the chats.
|
||||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||||
- Supporting the same profile on multiple devices.
|
- Supporting the same profile on multiple devices.
|
||||||
@@ -209,52 +334,9 @@ If you are considering developing with SimpleX platform please get in touch for
|
|||||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||||
- keep all your contacts and groups even if you lose the domain.
|
- keep all your contacts and groups even if you lose the domain.
|
||||||
- the server doesn't have information about your contacts and groups.
|
- the server doesn't have information about your contacts and groups.
|
||||||
- Channels server for large groups and broadcast channels.
|
- Hosting server for large groups, communities and public channels.
|
||||||
|
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||||
## Join a user group
|
- High capacity multi-node SMP relays.
|
||||||
|
|
||||||
You can join a general group with more than 100 members: [#SimpleX-Group](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FWHV0YU1sYlU7NqiEHkHDB6gxO1ofTync%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAWbebOqVYuBXaiqHcXYjEHCpYi6VzDlu6CVaijDTmsQU%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22mL-7Divb94GGmGmRBef5Dg%3D%3D%22%7D).
|
|
||||||
|
|
||||||
You can also join smaller groups by countries/languages: [\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FmIorjTDPG24jdLKXwutS6o9hdQQRZwfQ%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA9N0BZaECrAw3we3S1Wq4QO7NERBuPt9447immrB50wo%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22S8aISlOgkTMytSox9gAM2Q%3D%3D%22%7D) (German), [\#SimpleX-US](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FlTWmQplLEaoJyHnEL1-B3f2PtDsikcTs%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-hMBlsQjNxK2vaVhqW_UyAVtuoYqgYTigK4B9dJ9CGc%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22G0UtRHIn0TmPoo08h_cbTA%3D%3D%22%7D) (US/English), [\#SimpleX-France](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F11r6XyjwVMj0WDIUMbmNDXO996M_EN_1%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAXDmc2Lrj9WQOjEcWa0DeQHF3HcYOp9b68s8M_BJ7gEk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22EZCeSYpeIBkaQwCcpcF00w%3D%3D%22%7D), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FZSYM278L5WoZiApx3925EAjSXcsAVNVu%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA7RJ2wfT8zdfOLyE5OtWLEAPowj-q6F2HB0ExbATw8Gk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22fsVoklNGptt7n-droqJYUQ%3D%3D%22%7D) (Russian), [#SimpleX-NL](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmP0LbswSbfxoVkkxiWE2NYnBCgZ9Snvj%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAVwZuSsw4Mf52EaBNdNI3RebsLm0jg65ZIkcmH9E5uy8%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22M9xIULUNZx51Wsa5Kdb0Sg%3D%3D%22%7D) (Netherlands/Dutch), [#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaZ_wjh6QAYHB-LjyGtp8bllkzoq880u-%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA-_Wulzc3j16i7t77XJ5wgwxeW8_Ea8GxetMo7K4MgjI%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22QWmXdrFzIeMd2OoEPMFkBQ%3D%3D%22%7D) (Italian).
|
|
||||||
|
|
||||||
You can join these groups either by opening these links in the app or by opening them in a desktop browser and scanning QR code.
|
|
||||||
|
|
||||||
Let us know if you'd like to add some other countries to the list.
|
|
||||||
|
|
||||||
Join via the app to share what's going on and ask any questions!
|
|
||||||
|
|
||||||
## Contribute
|
|
||||||
|
|
||||||
We would love to have you join the development! You can contribute to SimpleX Chat with:
|
|
||||||
|
|
||||||
- developing features - please connect to us via chat so we can help you get started.
|
|
||||||
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
|
|
||||||
- translate UI to some language - we are currently setting up the UI to simplify it, please get in touch and let us know if you would be able to support and update the translations.
|
|
||||||
- translate website homepage - there is a lot of content we would like to share, it would help to bring the new users.
|
|
||||||
|
|
||||||
## Help us with donations
|
|
||||||
|
|
||||||
Huge thank you to everybody who donated to SimpleX Chat!
|
|
||||||
|
|
||||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
|
||||||
|
|
||||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
|
||||||
|
|
||||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, would make a big difference for us.
|
|
||||||
|
|
||||||
It is possible to donate via:
|
|
||||||
|
|
||||||
- [GitHub](https://github.com/sponsors/simplex-chat) - it is commission-free for us.
|
|
||||||
- [OpenCollective](https://opencollective.com/simplex-chat) - it charges a commission, and also accepts donations in crypto-currencies.
|
|
||||||
- Monero wallet: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt
|
|
||||||
- Bitcoin wallet: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG
|
|
||||||
- please let us know, via GitHub issue or chat, if you want to create a donation in some other cryptocurrency - we will add the address to the list.
|
|
||||||
|
|
||||||
Thank you,
|
|
||||||
|
|
||||||
Evgeny
|
|
||||||
|
|
||||||
SimpleX Chat founder
|
|
||||||
|
|
||||||
## Disclaimers
|
## Disclaimers
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,12 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "chat.simplex.app"
|
applicationId "chat.simplex.app"
|
||||||
minSdk 29
|
minSdk 26
|
||||||
targetSdk 32
|
targetSdk 32
|
||||||
versionCode 70
|
versionCode 117
|
||||||
versionName "4.2.2"
|
versionName "5.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
ndk {
|
|
||||||
abiFilters 'arm64-v8a'
|
|
||||||
}
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary true
|
useSupportLibrary true
|
||||||
}
|
}
|
||||||
@@ -76,6 +73,39 @@ android {
|
|||||||
}
|
}
|
||||||
jniLibs.useLegacyPackaging = compression_level != "0"
|
jniLibs.useLegacyPackaging = compression_level != "0"
|
||||||
}
|
}
|
||||||
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
|
def isBundle = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("bundle") }) != null
|
||||||
|
// if (isRelease) {
|
||||||
|
// Comma separated list of languages that will be included in the apk
|
||||||
|
android.defaultConfig.resConfigs(
|
||||||
|
"en",
|
||||||
|
"cs",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"it",
|
||||||
|
"nl",
|
||||||
|
"pl",
|
||||||
|
"ru",
|
||||||
|
"zh-rCN"
|
||||||
|
)
|
||||||
|
// }
|
||||||
|
if (isBundle) {
|
||||||
|
defaultConfig.ndk.abiFilters 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
enable true
|
||||||
|
reset()
|
||||||
|
if (isRelease) {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
} else {
|
||||||
|
include 'arm64-v8a', 'armeabi-v7a'
|
||||||
|
universalApk false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -89,7 +119,8 @@ dependencies {
|
|||||||
implementation 'androidx.fragment:fragment:1.4.1'
|
implementation 'androidx.fragment:fragment:1.4.1'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||||
implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
implementation 'com.charleskorn.kaml:kaml:0.43.0'
|
||||||
|
//implementation "androidx.compose.material:material-icons-extended:$compose_version"
|
||||||
implementation "androidx.compose.ui:ui-util:$compose_version"
|
implementation "androidx.compose.ui:ui-util:$compose_version"
|
||||||
implementation "androidx.navigation:navigation-compose:2.4.1"
|
implementation "androidx.navigation:navigation-compose:2.4.1"
|
||||||
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
implementation "com.google.accompanist:accompanist-insets:0.23.0"
|
||||||
@@ -124,6 +155,9 @@ dependencies {
|
|||||||
implementation "io.coil-kt:coil-compose:2.1.0"
|
implementation "io.coil-kt:coil-compose:2.1.0"
|
||||||
implementation "io.coil-kt:coil-gif:2.1.0"
|
implementation "io.coil-kt:coil-gif:2.1.0"
|
||||||
|
|
||||||
|
// Video support
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer:2.17.1"
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||||
@@ -131,19 +165,12 @@ dependencies {
|
|||||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
def buildType = "unknown"
|
|
||||||
// Don't do anything if no compression is needed
|
// Don't do anything if no compression is needed
|
||||||
if (compression_level != "0") {
|
if (compression_level != "0") {
|
||||||
tasks.whenTaskAdded { task ->
|
tasks.whenTaskAdded { task ->
|
||||||
if (task.name == 'packageDebug') {
|
if (task.name == 'packageDebug') {
|
||||||
task.doLast {
|
|
||||||
buildType = "debug"
|
|
||||||
}
|
|
||||||
task.finalizedBy compressApk
|
task.finalizedBy compressApk
|
||||||
} else if (task.name == 'packageRelease') {
|
} else if (task.name == 'packageRelease') {
|
||||||
task.doLast {
|
|
||||||
buildType = "release"
|
|
||||||
}
|
|
||||||
task.finalizedBy compressApk
|
task.finalizedBy compressApk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,6 +178,13 @@ if (compression_level != "0") {
|
|||||||
|
|
||||||
tasks.register("compressApk") {
|
tasks.register("compressApk") {
|
||||||
doLast {
|
doLast {
|
||||||
|
def isRelease = gradle.getStartParameter().taskNames.find({ it.toLowerCase().contains("release") }) != null
|
||||||
|
def buildType
|
||||||
|
if (isRelease) {
|
||||||
|
buildType = "release"
|
||||||
|
} else {
|
||||||
|
buildType = "debug"
|
||||||
|
}
|
||||||
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
def javaHome = System.properties['java.home'] ?: org.gradle.internal.jvm.Jvm.current().getJavaHome()
|
||||||
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
def sdkDir = android.getSdkDirectory().getAbsolutePath()
|
||||||
def keyAlias = ""
|
def keyAlias = ""
|
||||||
@@ -191,6 +225,8 @@ tasks.register("compressApk") {
|
|||||||
|
|
||||||
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
if (project.properties['android.injected.signing.key.alias'] != null && buildType == 'release') {
|
||||||
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
new File(outputDir, "app-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
|
new File(outputDir, "app-armeabi-v7a-release.apk").renameTo(new File(outputDir, "simplex-armv7a.apk"))
|
||||||
|
new File(outputDir, "app-arm64-v8a-release.apk").renameTo(new File(outputDir, "simplex.apk"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// View all gradle properties set
|
// View all gradle properties set
|
||||||
|
|||||||
@@ -24,11 +24,14 @@
|
|||||||
<application
|
<application
|
||||||
android:name="SimplexApp"
|
android:name="SimplexApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:fullBackupOnly="true"
|
||||||
|
android:backupAgent="BackupAgent"
|
||||||
android:icon="@mipmap/icon"
|
android:icon="@mipmap/icon"
|
||||||
android:label="${app_name}"
|
android:label="${app_name}"
|
||||||
android:extractNativeLibs="${extract_native_libs}"
|
android:extractNativeLibs="${extract_native_libs}"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.SimpleX">
|
android:theme="@style/Theme.SimpleX">
|
||||||
|
<!-- android:localeConfig="@xml/locales_config"-->
|
||||||
|
|
||||||
<!-- Main activity -->
|
<!-- Main activity -->
|
||||||
<activity
|
<activity
|
||||||
@@ -102,7 +105,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<activity android:name=".views.call.IncomingCallActivity"
|
<activity android:name=".views.call.IncomingCallActivity"
|
||||||
android:showOnLockScreen="true"/>
|
android:showOnLockScreen="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTask"/>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ var TransformOperation;
|
|||||||
TransformOperation["Decrypt"] = "decrypt";
|
TransformOperation["Decrypt"] = "decrypt";
|
||||||
})(TransformOperation || (TransformOperation = {}));
|
})(TransformOperation || (TransformOperation = {}));
|
||||||
let activeCall;
|
let activeCall;
|
||||||
|
let answerTimeout = 30000;
|
||||||
const processCommand = (function () {
|
const processCommand = (function () {
|
||||||
const defaultIceServers = [
|
const defaultIceServers = [
|
||||||
{ urls: ["stun:stun.simplex.im:443"] },
|
{ urls: ["stun:stun.simplex.im:443"] },
|
||||||
{ urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
{ urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||||
|
{ urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||||
];
|
];
|
||||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||||
return {
|
return {
|
||||||
@@ -100,9 +102,16 @@ const processCommand = (function () {
|
|||||||
const iceCandidates = getIceCandidates(pc, config);
|
const iceCandidates = getIceCandidates(pc, config);
|
||||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey, useWorker };
|
||||||
await setupMediaStreams(call);
|
await setupMediaStreams(call);
|
||||||
|
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||||
return call;
|
return call;
|
||||||
async function connectionStateChange() {
|
async function connectionStateChange() {
|
||||||
|
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
|
||||||
|
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
|
||||||
|
if (pc.connectionState !== "failed")
|
||||||
|
connectionHandler();
|
||||||
|
}
|
||||||
|
async function connectionHandler() {
|
||||||
sendMessageToNative({
|
sendMessageToNative({
|
||||||
resp: {
|
resp: {
|
||||||
type: "connection",
|
type: "connection",
|
||||||
@@ -115,6 +124,7 @@ const processCommand = (function () {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||||
|
clearConnectionTimeout();
|
||||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||||
if (activeCall) {
|
if (activeCall) {
|
||||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||||
@@ -122,6 +132,7 @@ const processCommand = (function () {
|
|||||||
endCall();
|
endCall();
|
||||||
}
|
}
|
||||||
else if (pc.connectionState == "connected") {
|
else if (pc.connectionState == "connected") {
|
||||||
|
clearConnectionTimeout();
|
||||||
const stats = (await pc.getStats());
|
const stats = (await pc.getStats());
|
||||||
for (const stat of stats.values()) {
|
for (const stat of stats.values()) {
|
||||||
const { type, state } = stat;
|
const { type, state } = stat;
|
||||||
@@ -141,6 +152,12 @@ const processCommand = (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function clearConnectionTimeout() {
|
||||||
|
if (connectionTimeout) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
connectionTimeout = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function serialize(x) {
|
function serialize(x) {
|
||||||
return LZString.compressToBase64(JSON.stringify(x));
|
return LZString.compressToBase64(JSON.stringify(x));
|
||||||
|
|||||||
@@ -53,10 +53,6 @@ add_library( support SHARED IMPORTED )
|
|||||||
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
set_target_properties( support PROPERTIES IMPORTED_LOCATION
|
||||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libsupport.so)
|
||||||
|
|
||||||
add_library( crypto SHARED IMPORTED )
|
|
||||||
set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
|
||||||
${CMAKE_SOURCE_DIR}/libs/${ANDROID_ABI}/libcrypto.so)
|
|
||||||
|
|
||||||
# Specifies libraries CMake should link to your target library. You
|
# Specifies libraries CMake should link to your target library. You
|
||||||
# can link multiple libraries, such as libraries you define in this
|
# can link multiple libraries, such as libraries you define in this
|
||||||
# build script, prebuilt third-party libraries, or system libraries.
|
# build script, prebuilt third-party libraries, or system libraries.
|
||||||
@@ -64,7 +60,7 @@ set_target_properties( crypto PROPERTIES IMPORTED_LOCATION
|
|||||||
target_link_libraries( # Specifies the target library.
|
target_link_libraries( # Specifies the target library.
|
||||||
app-lib
|
app-lib
|
||||||
|
|
||||||
simplex support crypto
|
simplex support
|
||||||
|
|
||||||
# Links the target library to the log library
|
# Links the target library to the log library
|
||||||
# included in the NDK.
|
# included in the NDK.
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ void hs_init(int * argc, char **argv[]);
|
|||||||
void setLineBuffering(void);
|
void setLineBuffering(void);
|
||||||
int pipe_std_to_socket(const char * name);
|
int pipe_std_to_socket(const char * name);
|
||||||
|
|
||||||
|
extern void __svfscanf(void){};
|
||||||
|
extern void __vfwscanf(void){};
|
||||||
|
extern void __memset_chk_fail(void){};
|
||||||
|
extern void __strcpy_chk_generic(void){};
|
||||||
|
extern void __strcat_chk_generic(void){};
|
||||||
|
extern void __libc_globals(void){};
|
||||||
|
extern void __rel_iplt_start(void){};
|
||||||
|
|
||||||
|
// Android 9 only, not 13
|
||||||
|
extern void reallocarray(void){};
|
||||||
|
|
||||||
JNIEXPORT jint JNICALL
|
JNIEXPORT jint JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
Java_chat_simplex_app_SimplexAppKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
|
||||||
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
|
||||||
@@ -24,20 +35,24 @@ Java_chat_simplex_app_SimplexAppKt_initHS(__unused JNIEnv *env, __unused jclass
|
|||||||
// from simplex-chat
|
// from simplex-chat
|
||||||
typedef long* chat_ctrl;
|
typedef long* chat_ctrl;
|
||||||
|
|
||||||
extern char *chat_migrate_init(const char *path, const char *key, chat_ctrl *ctrl);
|
extern char *chat_migrate_init(const char *path, const char *key, const char *confirm, chat_ctrl *ctrl);
|
||||||
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
extern char *chat_send_cmd(chat_ctrl ctrl, const char *cmd);
|
||||||
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
|
||||||
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
|
||||||
extern char *chat_parse_markdown(const char *str);
|
extern char *chat_parse_markdown(const char *str);
|
||||||
|
extern char *chat_parse_server(const char *str);
|
||||||
|
extern char *chat_password_hash(const char *pwd, const char *salt);
|
||||||
|
|
||||||
JNIEXPORT jobjectArray JNICALL
|
JNIEXPORT jobjectArray JNICALL
|
||||||
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey) {
|
Java_chat_simplex_app_SimplexAppKt_chatMigrateInit(JNIEnv *env, __unused jclass clazz, jstring dbPath, jstring dbKey, jstring confirm) {
|
||||||
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
const char *_dbPath = (*env)->GetStringUTFChars(env, dbPath, JNI_FALSE);
|
||||||
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
const char *_dbKey = (*env)->GetStringUTFChars(env, dbKey, JNI_FALSE);
|
||||||
|
const char *_confirm = (*env)->GetStringUTFChars(env, confirm, JNI_FALSE);
|
||||||
jlong _ctrl = (jlong) 0;
|
jlong _ctrl = (jlong) 0;
|
||||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, &_ctrl));
|
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||||
|
|
||||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||||
@@ -76,3 +91,21 @@ Java_chat_simplex_app_SimplexAppKt_chatParseMarkdown(JNIEnv *env, __unused jclas
|
|||||||
(*env)->ReleaseStringUTFChars(env, str, _str);
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatParseServer(JNIEnv *env, __unused jclass clazz, jstring str) {
|
||||||
|
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_parse_server(_str));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, str, _str);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
JNIEXPORT jstring JNICALL
|
||||||
|
Java_chat_simplex_app_SimplexAppKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
|
||||||
|
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);
|
||||||
|
const char *_salt = (*env)->GetStringUTFChars(env, salt, JNI_FALSE);
|
||||||
|
jstring res = (*env)->NewStringUTF(env, chat_password_hash(_pwd, _salt));
|
||||||
|
(*env)->ReleaseStringUTFChars(env, pwd, _pwd);
|
||||||
|
(*env)->ReleaseStringUTFChars(env, salt, _salt);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package chat.simplex.app
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgentHelper
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import chat.simplex.app.model.AppPreferences
|
||||||
|
import chat.simplex.app.model.AppPreferences.Companion.SHARED_PREFS_PRIVACY_FULL_BACKUP
|
||||||
|
|
||||||
|
class BackupAgent: BackupAgentHelper() {
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput?) {
|
||||||
|
if (applicationContext
|
||||||
|
.getSharedPreferences(AppPreferences.SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(SHARED_PREFS_PRIVACY_FULL_BACKUP, true)
|
||||||
|
) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,29 +3,31 @@ package chat.simplex.app
|
|||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.*
|
||||||
import android.os.Parcelable
|
|
||||||
import android.os.SystemClock.elapsedRealtime
|
import android.os.SystemClock.elapsedRealtime
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.Surface
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.MainActivity.Companion.enteredBackground
|
||||||
import chat.simplex.app.model.NtfManager
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleButton
|
import chat.simplex.app.model.NtfManager.Companion.getUserIdFromIntent
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.SplashView
|
import chat.simplex.app.views.SplashView
|
||||||
import chat.simplex.app.views.call.ActiveCallView
|
import chat.simplex.app.views.call.ActiveCallView
|
||||||
import chat.simplex.app.views.call.IncomingCallAlertView
|
import chat.simplex.app.views.call.IncomingCallAlertView
|
||||||
@@ -33,9 +35,13 @@ import chat.simplex.app.views.chat.ChatView
|
|||||||
import chat.simplex.app.views.chatlist.*
|
import chat.simplex.app.views.chatlist.*
|
||||||
import chat.simplex.app.views.database.DatabaseErrorView
|
import chat.simplex.app.views.database.DatabaseErrorView
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.helpers.DatabaseUtils.ksAppPassword
|
||||||
|
import chat.simplex.app.views.localauth.SetAppPasscodeView
|
||||||
import chat.simplex.app.views.newchat.*
|
import chat.simplex.app.views.newchat.*
|
||||||
import chat.simplex.app.views.onboarding.*
|
import chat.simplex.app.views.onboarding.*
|
||||||
import kotlinx.coroutines.delay
|
import chat.simplex.app.views.usersettings.LAMode
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
class MainActivity: FragmentActivity() {
|
class MainActivity: FragmentActivity() {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -60,6 +66,7 @@ class MainActivity: FragmentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// testJson()
|
// testJson()
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
|
applyAppLocale(m.controller.appPrefs.appLanguage)
|
||||||
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
// When call ended and orientation changes, it re-process old intent, it's unneeded.
|
||||||
// Only needed to be processed on first creation of activity
|
// Only needed to be processed on first creation of activity
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
@@ -67,22 +74,23 @@ class MainActivity: FragmentActivity() {
|
|||||||
processIntent(intent, m)
|
processIntent(intent, m)
|
||||||
processExternalIntent(intent, m)
|
processExternalIntent(intent, m)
|
||||||
}
|
}
|
||||||
|
if (m.controller.appPrefs.privacyProtectScreen.get()) {
|
||||||
|
Log.d(TAG, "onCreate: set FLAG_SECURE")
|
||||||
|
window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
)
|
||||||
|
}
|
||||||
setContent {
|
setContent {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
MainPage(
|
||||||
Modifier
|
m,
|
||||||
.background(MaterialTheme.colors.background)
|
userAuthorized,
|
||||||
.fillMaxSize()
|
laFailed,
|
||||||
) {
|
::runAuthenticate,
|
||||||
MainPage(
|
::setPerformLA,
|
||||||
m,
|
showLANotice = { showLANotice(m.controller.appPrefs.laNoticeShown, this) }
|
||||||
userAuthorized,
|
)
|
||||||
laFailed,
|
|
||||||
::runAuthenticate,
|
|
||||||
::setPerformLA,
|
|
||||||
showLANotice = { m.controller.showLANotice(this) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
SimplexApp.context.schedulePeriodicServiceRestartWorker()
|
||||||
@@ -95,10 +103,11 @@ class MainActivity: FragmentActivity() {
|
|||||||
processExternalIntent(intent, vm.chatModel)
|
processExternalIntent(intent, vm.chatModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onResume() {
|
||||||
super.onStart()
|
super.onResume()
|
||||||
val enteredBackgroundVal = enteredBackground.value
|
val enteredBackgroundVal = enteredBackground.value
|
||||||
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= 30_000) {
|
val delay = vm.chatModel.controller.appPrefs.laLockDelay.get()
|
||||||
|
if (enteredBackgroundVal == null || elapsedRealtime() - enteredBackgroundVal >= delay * 1000) {
|
||||||
runAuthenticate()
|
runAuthenticate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,11 +124,20 @@ class MainActivity: FragmentActivity() {
|
|||||||
|
|
||||||
override fun onStop() {
|
override fun onStop() {
|
||||||
super.onStop()
|
super.onStop()
|
||||||
|
VideoPlayer.stopAll()
|
||||||
enteredBackground.value = elapsedRealtime()
|
enteredBackground.value = elapsedRealtime()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
super.onBackPressed()
|
if (
|
||||||
|
onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack
|
||||||
|
|| Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above
|
||||||
|
|| isTaskRoot // there are still other tasks after we reach the main (home) activity
|
||||||
|
) {
|
||||||
|
// https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06
|
||||||
|
super.onBackPressed()
|
||||||
|
}
|
||||||
|
|
||||||
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
if (!onBackPressedDispatcher.hasEnabledCallbacks() && vm.chatModel.controller.appPrefs.performLA.get()) {
|
||||||
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
|
||||||
clearAuthState()
|
clearAuthState()
|
||||||
@@ -138,43 +156,155 @@ class MainActivity: FragmentActivity() {
|
|||||||
} else {
|
} else {
|
||||||
userAuthorized.value = false
|
userAuthorized.value = false
|
||||||
ModalManager.shared.closeModals()
|
ModalManager.shared.closeModals()
|
||||||
authenticate(
|
// To make Main thread free in order to allow to Compose to show blank view that hiding content underneath of it faster on slow devices
|
||||||
generalGetString(R.string.auth_unlock),
|
CoroutineScope(Dispatchers.Default).launch {
|
||||||
generalGetString(R.string.auth_log_in_using_credential),
|
delay(50)
|
||||||
this@MainActivity,
|
withContext(Dispatchers.Main) {
|
||||||
completed = { laResult ->
|
authenticate(
|
||||||
when (laResult) {
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
LAResult.Success ->
|
generalGetString(R.string.auth_unlock)
|
||||||
userAuthorized.value = true
|
else
|
||||||
is LAResult.Error, LAResult.Failed ->
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
laFailed.value = true
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
LAResult.Unavailable -> {
|
generalGetString(R.string.auth_log_in_using_credential)
|
||||||
userAuthorized.value = true
|
else
|
||||||
m.performLA.value = false
|
generalGetString(R.string.auth_unlock),
|
||||||
m.controller.appPrefs.performLA.set(false)
|
this@MainActivity,
|
||||||
laUnavailableTurningOffAlert()
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success ->
|
||||||
|
userAuthorized.value = true
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
laFailed.value = true
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.PASSCODE) {
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
userAuthorized.value = true
|
||||||
|
m.performLA.value = false
|
||||||
|
m.controller.appPrefs.performLA.set(false)
|
||||||
|
laUnavailableTurningOffAlert()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLANotice(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
|
Log.d(TAG, "showLANotice")
|
||||||
|
if (!laNoticeShown.get()) {
|
||||||
|
laNoticeShown.set(true)
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.la_notice_title_simplex_lock),
|
||||||
|
text = generalGetString(R.string.la_notice_to_protect_your_information_turn_on_simplex_lock_you_will_be_prompted_to_complete_authentication_before_this_feature_is_enabled),
|
||||||
|
confirmText = generalGetString(R.string.la_notice_turn_on),
|
||||||
|
onConfirm = {
|
||||||
|
withBGApi { // to remove this call, change ordering of onConfirm call in AlertManager
|
||||||
|
showChooseLAMode(laNoticeShown, activity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setPerformLA(on: Boolean) {
|
private fun showChooseLAMode(laNoticeShown: SharedPreference<Boolean>, activity: FragmentActivity) {
|
||||||
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
Log.d(TAG, "showLANotice")
|
||||||
if (on) {
|
laNoticeShown.set(true)
|
||||||
enableLA()
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
} else {
|
title = generalGetString(R.string.la_lock_mode),
|
||||||
disableLA()
|
text = null,
|
||||||
}
|
confirmText = generalGetString(R.string.la_lock_mode_passcode),
|
||||||
|
dismissText = generalGetString(R.string.la_lock_mode_system),
|
||||||
|
onConfirm = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
setPasscode()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
AlertManager.shared.hideAlert()
|
||||||
|
initialEnableLA(activity)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enableLA() {
|
private fun initialEnableLA(activity: FragmentActivity) {
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
|
val appPrefs = m.controller.appPrefs
|
||||||
|
m.controller.appPrefs.laMode.set(LAMode.SYSTEM)
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_enable_simplex_lock),
|
generalGetString(R.string.auth_enable_simplex_lock),
|
||||||
generalGetString(R.string.auth_confirm_credential),
|
generalGetString(R.string.auth_confirm_credential),
|
||||||
this@MainActivity,
|
activity,
|
||||||
|
completed = { laResult ->
|
||||||
|
when (laResult) {
|
||||||
|
LAResult.Success -> {
|
||||||
|
m.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
|
}
|
||||||
|
is LAResult.Unavailable -> {
|
||||||
|
m.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
m.showAdvertiseLAUnavailableAlert.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPasscode() {
|
||||||
|
val chatModel = vm.chatModel
|
||||||
|
val appPrefs = chatModel.controller.appPrefs
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
|
||||||
|
SetAppPasscodeView(
|
||||||
|
submit = {
|
||||||
|
chatModel.performLA.value = true
|
||||||
|
appPrefs.performLA.set(true)
|
||||||
|
appPrefs.laMode.set(LAMode.PASSCODE)
|
||||||
|
laTurnedOnAlert()
|
||||||
|
},
|
||||||
|
cancel = {
|
||||||
|
chatModel.performLA.value = false
|
||||||
|
appPrefs.performLA.set(false)
|
||||||
|
laPasscodeNotSetAlert()
|
||||||
|
},
|
||||||
|
close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPerformLA(on: Boolean, activity: FragmentActivity) {
|
||||||
|
vm.chatModel.controller.appPrefs.laNoticeShown.set(true)
|
||||||
|
if (on) {
|
||||||
|
enableLA(activity)
|
||||||
|
} else {
|
||||||
|
disableLA(activity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableLA(activity: FragmentActivity) {
|
||||||
|
val m = vm.chatModel
|
||||||
|
authenticate(
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_enable_simplex_lock)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.new_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
"",
|
||||||
|
activity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
val prefPerformLA = m.controller.appPrefs.performLA
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
@@ -183,11 +313,13 @@ class MainActivity: FragmentActivity() {
|
|||||||
prefPerformLA.set(true)
|
prefPerformLA.set(true)
|
||||||
laTurnedOnAlert()
|
laTurnedOnAlert()
|
||||||
}
|
}
|
||||||
is LAResult.Error, LAResult.Failed -> {
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
|
laFailedAlert()
|
||||||
}
|
}
|
||||||
LAResult.Unavailable -> {
|
is LAResult.Unavailable -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
laUnavailableInstructionAlert()
|
laUnavailableInstructionAlert()
|
||||||
@@ -197,24 +329,33 @@ class MainActivity: FragmentActivity() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun disableLA() {
|
private fun disableLA(activity: FragmentActivity) {
|
||||||
val m = vm.chatModel
|
val m = vm.chatModel
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_disable_simplex_lock),
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
generalGetString(R.string.auth_confirm_credential),
|
generalGetString(R.string.auth_disable_simplex_lock)
|
||||||
this@MainActivity,
|
else
|
||||||
|
generalGetString(R.string.la_enter_app_passcode),
|
||||||
|
if (m.controller.appPrefs.laMode.get() == LAMode.SYSTEM)
|
||||||
|
generalGetString(R.string.auth_confirm_credential)
|
||||||
|
else
|
||||||
|
generalGetString(R.string.auth_disable_simplex_lock),
|
||||||
|
activity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
val prefPerformLA = m.controller.appPrefs.performLA
|
val prefPerformLA = m.controller.appPrefs.performLA
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
LAResult.Success -> {
|
LAResult.Success -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
|
ksAppPassword.remove()
|
||||||
}
|
}
|
||||||
is LAResult.Error, LAResult.Failed -> {
|
is LAResult.Failed -> { /* Can be called multiple times on every failure */ }
|
||||||
|
is LAResult.Error -> {
|
||||||
m.performLA.value = true
|
m.performLA.value = true
|
||||||
prefPerformLA.set(true)
|
prefPerformLA.set(true)
|
||||||
|
laFailedAlert()
|
||||||
}
|
}
|
||||||
LAResult.Unavailable -> {
|
is LAResult.Unavailable -> {
|
||||||
m.performLA.value = false
|
m.performLA.value = false
|
||||||
prefPerformLA.set(false)
|
prefPerformLA.set(false)
|
||||||
laUnavailableTurningOffAlert()
|
laUnavailableTurningOffAlert()
|
||||||
@@ -236,17 +377,9 @@ fun MainPage(
|
|||||||
userAuthorized: MutableState<Boolean?>,
|
userAuthorized: MutableState<Boolean?>,
|
||||||
laFailed: MutableState<Boolean>,
|
laFailed: MutableState<Boolean>,
|
||||||
runAuthenticate: () -> Unit,
|
runAuthenticate: () -> Unit,
|
||||||
setPerformLA: (Boolean) -> Unit,
|
setPerformLA: (Boolean, FragmentActivity) -> Unit,
|
||||||
showLANotice: () -> Unit
|
showLANotice: () -> Unit
|
||||||
) {
|
) {
|
||||||
// this with LaunchedEffect(userAuthorized.value) fixes bottom sheet visibly collapsing after authentication
|
|
||||||
var chatsAccessAuthorized by rememberSaveable { mutableStateOf(false) }
|
|
||||||
LaunchedEffect(userAuthorized.value) {
|
|
||||||
if (chatModel.controller.appPrefs.performLA.get()) {
|
|
||||||
delay(500L)
|
|
||||||
}
|
|
||||||
chatsAccessAuthorized = userAuthorized.value == true
|
|
||||||
}
|
|
||||||
var showChatDatabaseError by rememberSaveable {
|
var showChatDatabaseError by rememberSaveable {
|
||||||
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
mutableStateOf(chatModel.chatDbStatus.value != DBMigrationResult.OK && chatModel.chatDbStatus.value != null)
|
||||||
}
|
}
|
||||||
@@ -281,12 +414,12 @@ fun MainPage(
|
|||||||
@Composable
|
@Composable
|
||||||
fun authView() {
|
fun authView() {
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.auth_unlock),
|
stringResource(R.string.auth_unlock),
|
||||||
icon = Icons.Outlined.Lock,
|
icon = painterResource(R.drawable.ic_lock),
|
||||||
click = {
|
click = {
|
||||||
laFailed.value = false
|
laFailed.value = false
|
||||||
runAuthenticate()
|
runAuthenticate()
|
||||||
@@ -305,7 +438,7 @@ fun MainPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onboarding == null || userCreated == null -> SplashView()
|
onboarding == null || userCreated == null -> SplashView()
|
||||||
!chatsAccessAuthorized -> {
|
userAuthorized.value != true -> {
|
||||||
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
if (chatModel.controller.appPrefs.performLA.get() && laFailed.value) {
|
||||||
authView()
|
authView()
|
||||||
} else {
|
} else {
|
||||||
@@ -317,42 +450,99 @@ fun MainPage(
|
|||||||
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
if (chatModel.showCallView.value) ActiveCallView(chatModel)
|
||||||
else {
|
else {
|
||||||
showAdvertiseLAAlert = true
|
showAdvertiseLAAlert = true
|
||||||
val stopped = chatModel.chatRunning.value == false
|
BoxWithConstraints {
|
||||||
if (chatModel.chatId.value == null) {
|
var currentChatId by rememberSaveable { mutableStateOf(chatModel.chatId.value) }
|
||||||
if (chatModel.sharedContent.value == null)
|
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
|
||||||
ChatListView(chatModel, setPerformLA, stopped)
|
Box(
|
||||||
else
|
Modifier
|
||||||
ShareListView(chatModel, stopped)
|
.graphicsLayer {
|
||||||
|
translationX = -offset.value.dp.toPx()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
if (chatModel.sharedContent.value == null)
|
||||||
|
ChatListView(chatModel, setPerformLA, stopped)
|
||||||
|
else
|
||||||
|
ShareListView(chatModel, stopped)
|
||||||
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val onComposed: () -> Unit = {
|
||||||
|
scope.launch {
|
||||||
|
offset.animateTo(
|
||||||
|
if (chatModel.chatId.value == null) 0f else maxWidth.value,
|
||||||
|
chatListAnimationSpec()
|
||||||
|
)
|
||||||
|
if (offset.value == 0f) {
|
||||||
|
currentChatId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { chatModel.chatId.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
if (it != null) currentChatId = it
|
||||||
|
else onComposed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box (Modifier.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }) Box2@ {
|
||||||
|
currentChatId?.let {
|
||||||
|
ChatView(it, chatModel, onComposed)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else ChatView(chatModel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
onboarding == OnboardingStage.Step1_SimpleXInfo -> SimpleXInfo(chatModel, onboarding = true)
|
||||||
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel)
|
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {}
|
||||||
|
onboarding == OnboardingStage.Step3_SetNotificationsMode -> SetNotificationsMode(chatModel)
|
||||||
}
|
}
|
||||||
ModalManager.shared.showInView()
|
ModalManager.shared.showInView()
|
||||||
val invitation = chatModel.activeCallInvitation.value
|
val invitation = chatModel.activeCallInvitation.value
|
||||||
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
if (invitation != null) IncomingCallAlertView(invitation, chatModel)
|
||||||
AlertManager.shared.showInView()
|
AlertManager.shared.showInView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DisposableEffectOnRotate {
|
||||||
|
// When using lock delay = 0 and screen rotates, the app will be locked which is not useful.
|
||||||
|
// Let's prolong the unlocked period to 3 sec for screen rotation to take place
|
||||||
|
if (chatModel.controller.appPrefs.laLockDelay.get() == 0) {
|
||||||
|
enteredBackground.value = elapsedRealtime() + 3000
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
fun processNotificationIntent(intent: Intent?, chatModel: ChatModel) {
|
||||||
|
val userId = getUserIdFromIntent(intent)
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
NtfManager.OpenChatAction -> {
|
NtfManager.OpenChatAction -> {
|
||||||
val chatId = intent.getStringExtra("chatId")
|
val chatId = intent.getStringExtra("chatId")
|
||||||
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
Log.d(TAG, "processNotificationIntent: OpenChatAction $chatId")
|
||||||
if (chatId != null) {
|
if (chatId != null) {
|
||||||
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
withBGApi {
|
||||||
chatModel.clearOverlays.value = true
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
if (cInfo != null) withApi { openChat(cInfo, chatModel) }
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
|
}
|
||||||
|
val cInfo = chatModel.getChat(chatId)?.chatInfo
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
if (cInfo != null) openChat(cInfo, chatModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NtfManager.ShowChatsAction -> {
|
NtfManager.ShowChatsAction -> {
|
||||||
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
Log.d(TAG, "processNotificationIntent: ShowChatsAction")
|
||||||
chatModel.chatId.value = null
|
withBGApi {
|
||||||
chatModel.clearOverlays.value = true
|
awaitChatStartedIfNeeded(chatModel)
|
||||||
|
if (userId != null && userId != chatModel.currentUser.value?.userId && chatModel.currentUser.value != null) {
|
||||||
|
chatModel.controller.changeActiveUser(userId, null)
|
||||||
|
}
|
||||||
|
chatModel.chatId.value = null
|
||||||
|
chatModel.clearOverlays.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
NtfManager.AcceptCallAction -> {
|
NtfManager.AcceptCallAction -> {
|
||||||
val chatId = intent.getStringExtra("chatId")
|
val chatId = intent.getStringExtra("chatId")
|
||||||
@@ -385,14 +575,23 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
when {
|
when {
|
||||||
"text/plain" == intent.type -> intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
intent.type == "text/plain" -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Text(it)
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
if (text != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Text(text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
isMediaIntent(intent) -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(it))
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
} // All other mime types
|
if (uri != null) {
|
||||||
else -> (intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri)?.let {
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri))
|
||||||
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
} // All other mime types
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
|
||||||
|
if (uri != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,20 +599,26 @@ fun processExternalIntent(intent: Intent?, chatModel: ChatModel) {
|
|||||||
// Close active chat and show a list of chats
|
// Close active chat and show a list of chats
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
chatModel.clearOverlays.value = true
|
chatModel.clearOverlays.value = true
|
||||||
|
Log.e(TAG, "ACTION_SEND_MULTIPLE ${intent.type}")
|
||||||
when {
|
when {
|
||||||
intent.type?.startsWith("image/") == true -> (intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>)?.let {
|
isMediaIntent(intent) -> {
|
||||||
chatModel.sharedContent.value = SharedContent.Images(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", it)
|
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
|
||||||
} // All other mime types
|
if (uris != null) {
|
||||||
|
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris)
|
||||||
|
} // All other mime types
|
||||||
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isMediaIntent(intent: Intent): Boolean =
|
||||||
|
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
|
||||||
|
|
||||||
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
||||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||||
if (chatModel.currentUser.value == null) {
|
if (chatModel.currentUser.value == null) {
|
||||||
// TODO open from chat list view
|
|
||||||
chatModel.appOpenUrl.value = uri
|
chatModel.appOpenUrl.value = uri
|
||||||
} else {
|
} else {
|
||||||
withUriAction(uri) { linkType ->
|
withUriAction(uri) { linkType ->
|
||||||
@@ -422,7 +627,7 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
|||||||
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
ConnectionLinkType.INVITATION -> generalGetString(R.string.connect_via_invitation_link)
|
||||||
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
ConnectionLinkType.GROUP -> generalGetString(R.string.connect_via_group_link)
|
||||||
}
|
}
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = title,
|
title = title,
|
||||||
text = if (linkType == ConnectionLinkType.GROUP)
|
text = if (linkType == ConnectionLinkType.GROUP)
|
||||||
generalGetString(R.string.you_will_join_group)
|
generalGetString(R.string.you_will_join_group)
|
||||||
@@ -439,6 +644,20 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun awaitChatStartedIfNeeded(chatModel: ChatModel, timeout: Long = 30_000) {
|
||||||
|
// Still decrypting database
|
||||||
|
if (chatModel.chatRunning.value == null) {
|
||||||
|
val step = 50L
|
||||||
|
for (i in 0..(timeout / step)) {
|
||||||
|
if (chatModel.chatRunning.value == true || chatModel.onboardingStage.value == OnboardingStage.Step1_SimpleXInfo) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
//fun testJson() {
|
//fun testJson() {
|
||||||
// val str: String = """
|
// val str: String = """
|
||||||
// """.trimIndent()
|
// """.trimIndent()
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.*
|
import androidx.lifecycle.*
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.DefaultTheme
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import java.io.BufferedReader
|
import java.io.*
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.Semaphore
|
import java.util.concurrent.Semaphore
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -27,19 +27,26 @@ external fun pipeStdOutToSocket(socketName: String) : Int
|
|||||||
|
|
||||||
// SimpleX API
|
// SimpleX API
|
||||||
typealias ChatCtrl = Long
|
typealias ChatCtrl = Long
|
||||||
external fun chatMigrateInit(dbPath: String, dbKey: String): Array<Any>
|
external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array<Any>
|
||||||
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
external fun chatSendCmd(ctrl: ChatCtrl, msg: String): String
|
||||||
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
external fun chatRecvMsg(ctrl: ChatCtrl): String
|
||||||
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
|
||||||
external fun chatParseMarkdown(str: String): String
|
external fun chatParseMarkdown(str: String): String
|
||||||
|
external fun chatParseServer(str: String): String
|
||||||
|
external fun chatPasswordHash(pwd: String, salt: String): String
|
||||||
|
|
||||||
class SimplexApp: Application(), LifecycleEventObserver {
|
class SimplexApp: Application(), LifecycleEventObserver {
|
||||||
lateinit var chatController: ChatController
|
lateinit var chatController: ChatController
|
||||||
|
|
||||||
fun initChatController(useKey: String? = null, startChat: Boolean = true) {
|
var isAppOnForeground: Boolean = false
|
||||||
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey() ?: ""
|
|
||||||
|
val defaultLocale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: Boolean = true) {
|
||||||
|
val dbKey = useKey ?: DatabaseUtils.useDatabaseKey()
|
||||||
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
val dbAbsolutePathPrefix = getFilesDirectory(SimplexApp.context)
|
||||||
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey)
|
val confirm = confirmMigrations ?: if (appPreferences.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||||
|
val migrated: Array<Any> = chatMigrateInit(dbAbsolutePathPrefix, dbKey, confirm.value)
|
||||||
val res: DBMigrationResult = kotlin.runCatching {
|
val res: DBMigrationResult = kotlin.runCatching {
|
||||||
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
json.decodeFromString<DBMigrationResult>(migrated[0] as String)
|
||||||
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
}.getOrElse { DBMigrationResult.Unknown(migrated[0] as String) }
|
||||||
@@ -88,6 +95,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
context = this
|
context = this
|
||||||
initChatController()
|
initChatController()
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
|
context.getDir("temp", MODE_PRIVATE).deleteRecursively()
|
||||||
|
runMigrations()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
|
||||||
@@ -95,14 +104,27 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
withApi {
|
withApi {
|
||||||
when (event) {
|
when (event) {
|
||||||
Lifecycle.Event.ON_START -> {
|
Lifecycle.Event.ON_START -> {
|
||||||
|
isAppOnForeground = true
|
||||||
if (chatModel.chatRunning.value == true) {
|
if (chatModel.chatRunning.value == true) {
|
||||||
kotlin.runCatching {
|
kotlin.runCatching {
|
||||||
val chats = chatController.apiGetChats()
|
val currentUserId = chatModel.currentUser.value?.userId
|
||||||
chatModel.updateChats(chats)
|
val chats = ArrayList(chatController.apiGetChats())
|
||||||
|
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */
|
||||||
|
if (chatModel.currentUser.value?.userId == currentUserId) {
|
||||||
|
val currentChatId = chatModel.chatId.value
|
||||||
|
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
|
||||||
|
if (oldStats != null) {
|
||||||
|
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
|
||||||
|
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
|
||||||
|
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
|
||||||
|
}
|
||||||
|
chatModel.updateChats(chats)
|
||||||
|
}
|
||||||
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Lifecycle.Event.ON_RESUME -> {
|
Lifecycle.Event.ON_RESUME -> {
|
||||||
|
isAppOnForeground = true
|
||||||
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
if (chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete) {
|
||||||
chatController.showBackgroundServiceNoticeIfNeeded()
|
chatController.showBackgroundServiceNoticeIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -111,10 +133,14 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
* after calling [ChatController.showBackgroundServiceNoticeIfNeeded] notification mode in prefs can be changed.
|
||||||
* It can happen when app was started and a user enables battery optimization while app in background
|
* It can happen when app was started and a user enables battery optimization while app in background
|
||||||
* */
|
* */
|
||||||
if (chatModel.chatRunning.value != false && appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name)
|
if (chatModel.chatRunning.value != false &&
|
||||||
|
chatModel.onboardingStage.value == OnboardingStage.OnboardingComplete &&
|
||||||
|
appPreferences.notificationsMode.get() == NotificationsMode.SERVICE.name
|
||||||
|
) {
|
||||||
SimplexService.start(applicationContext)
|
SimplexService.start(applicationContext)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> isAppOnForeground = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +186,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
MessagesFetcherWorker.scheduleWork()
|
MessagesFetcherWorker.scheduleWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun runMigrations() {
|
||||||
|
val lastMigration = chatModel.controller.appPrefs.lastMigratedVersionCode
|
||||||
|
if (lastMigration.get() < BuildConfig.VERSION_CODE) {
|
||||||
|
while (true) {
|
||||||
|
if (lastMigration.get() < 117) {
|
||||||
|
if (chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.DARK.name) {
|
||||||
|
chatModel.controller.appPrefs.currentTheme.set(DefaultTheme.SIMPLEX.name)
|
||||||
|
}
|
||||||
|
lastMigration.set(117)
|
||||||
|
} else {
|
||||||
|
lastMigration.set(BuildConfig.VERSION_CODE)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var context: SimplexApp private set
|
lateinit var context: SimplexApp private set
|
||||||
|
|
||||||
@@ -168,7 +211,18 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||||||
val s = Semaphore(0)
|
val s = Semaphore(0)
|
||||||
thread(name="stdout/stderr pipe") {
|
thread(name="stdout/stderr pipe") {
|
||||||
Log.d(TAG, "starting server")
|
Log.d(TAG, "starting server")
|
||||||
val server = LocalServerSocket(socketName)
|
var server: LocalServerSocket? = null
|
||||||
|
for (i in 0..100) {
|
||||||
|
try {
|
||||||
|
server = LocalServerSocket(socketName + i)
|
||||||
|
break
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (server == null) {
|
||||||
|
throw Error("Unable to setup local server socket. Contact developers")
|
||||||
|
}
|
||||||
Log.d(TAG, "started server")
|
Log.d(TAG, "started server")
|
||||||
s.release()
|
s.release()
|
||||||
val receiver = server.accept()
|
val receiver = server.accept()
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.core.app.NotificationCompat
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
class SimplexService: Service() {
|
class SimplexService: Service() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var isServiceStarted = false
|
|
||||||
private var isStartingService = false
|
private var isStartingService = false
|
||||||
private var notificationManager: NotificationManager? = null
|
private var notificationManager: NotificationManager? = null
|
||||||
private var serviceNotification: Notification? = null
|
private var serviceNotification: Notification? = null
|
||||||
@@ -48,11 +46,32 @@ class SimplexService: Service() {
|
|||||||
notificationManager = createNotificationChannel()
|
notificationManager = createNotificationChannel()
|
||||||
serviceNotification = createNotification(title, text)
|
serviceNotification = createNotification(title, text)
|
||||||
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
startForeground(SIMPLEX_SERVICE_ID, serviceNotification)
|
||||||
|
/**
|
||||||
|
* The reason [stopAfterStart] exists is because when the service is not called [startForeground] yet, and
|
||||||
|
* we call [stopSelf] on the same service, [ForegroundServiceDidNotStartInTimeException] will be thrown.
|
||||||
|
* To prevent that, we can call [stopSelf] only when the service made [startForeground] call
|
||||||
|
* */
|
||||||
|
if (stopAfterStart) {
|
||||||
|
stopForeground(true)
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
isServiceStarted = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
Log.d(TAG, "Simplex service destroyed")
|
Log.d(TAG, "Simplex service destroyed")
|
||||||
stopService()
|
try {
|
||||||
|
wakeLock?.let {
|
||||||
|
while (it.isHeld) it.release() // release all, in case acquired more than once
|
||||||
|
}
|
||||||
|
wakeLock = null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.d(TAG, "Exception while releasing wakelock: ${e.message}")
|
||||||
|
}
|
||||||
|
isServiceStarted = false
|
||||||
|
stopAfterStart = false
|
||||||
|
saveServiceState(this, ServiceState.STOPPED)
|
||||||
|
|
||||||
// If notification service is enabled and battery optimization is disabled, restart the service
|
// If notification service is enabled and battery optimization is disabled, restart the service
|
||||||
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
if (SimplexApp.context.allowToStartServiceAfterAppExit())
|
||||||
@@ -62,7 +81,7 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun startService() {
|
private fun startService() {
|
||||||
Log.d(TAG, "SimplexService startService")
|
Log.d(TAG, "SimplexService startService")
|
||||||
if (isServiceStarted || isStartingService) return
|
if (wakeLock != null || isStartingService) return
|
||||||
val self = this
|
val self = this
|
||||||
isStartingService = true
|
isStartingService = true
|
||||||
withApi {
|
withApi {
|
||||||
@@ -73,10 +92,9 @@ class SimplexService: Service() {
|
|||||||
if (chatDbStatus != DBMigrationResult.OK) {
|
if (chatDbStatus != DBMigrationResult.OK) {
|
||||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||||
showPassphraseNotification(chatDbStatus)
|
showPassphraseNotification(chatDbStatus)
|
||||||
stopService()
|
safeStopService(self)
|
||||||
return@withApi
|
return@withApi
|
||||||
}
|
}
|
||||||
isServiceStarted = true
|
|
||||||
saveServiceState(self, ServiceState.STARTED)
|
saveServiceState(self, ServiceState.STARTED)
|
||||||
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
|
||||||
@@ -89,22 +107,6 @@ class SimplexService: Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopService() {
|
|
||||||
Log.d(TAG, "Stopping foreground service")
|
|
||||||
try {
|
|
||||||
wakeLock?.let {
|
|
||||||
while (it.isHeld) it.release() // release all, in case acquired more than once
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
stopForeground(true)
|
|
||||||
stopSelf()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.d(TAG, "Service stopped without being started: ${e.message}")
|
|
||||||
}
|
|
||||||
isServiceStarted = false
|
|
||||||
saveServiceState(this, ServiceState.STOPPED)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createNotificationChannel(): NotificationManager? {
|
private fun createNotificationChannel(): NotificationManager? {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -235,6 +237,9 @@ class SimplexService: Service() {
|
|||||||
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
private const val SHARED_PREFS_SERVICE_STATE = "SIMPLEX_SERVICE_STATE"
|
||||||
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
private const val WORK_NAME_ONCE = "ServiceStartWorkerOnce"
|
||||||
|
|
||||||
|
private var isServiceStarted = false
|
||||||
|
private var stopAfterStart = false
|
||||||
|
|
||||||
fun scheduleStart(context: Context) {
|
fun scheduleStart(context: Context) {
|
||||||
Log.d(TAG, "Enqueuing work to start subscriber service")
|
Log.d(TAG, "Enqueuing work to start subscriber service")
|
||||||
val workManager = WorkManager.getInstance(context)
|
val workManager = WorkManager.getInstance(context)
|
||||||
@@ -244,7 +249,17 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
suspend fun start(context: Context) = serviceAction(context, Action.START)
|
||||||
|
|
||||||
fun stop(context: Context) = context.stopService(Intent(context, SimplexService::class.java))
|
/**
|
||||||
|
* If there is a need to stop the service, use this function only. It makes sure that the service will be stopped without an
|
||||||
|
* exception related to foreground services lifecycle
|
||||||
|
* */
|
||||||
|
fun safeStopService(context: Context) {
|
||||||
|
if (isServiceStarted) {
|
||||||
|
context.stopService(Intent(context, SimplexService::class.java))
|
||||||
|
} else {
|
||||||
|
stopAfterStart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun serviceAction(context: Context, action: Action) {
|
private suspend fun serviceAction(context: Context, action: Action) {
|
||||||
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
Log.d(TAG, "SimplexService serviceAction: ${action.name}")
|
||||||
@@ -311,4 +326,4 @@ class SimplexService: Service() {
|
|||||||
|
|
||||||
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
private fun getPreferences(context: Context): SharedPreferences = context.getSharedPreferences(SHARED_PREFS_ID, Context.MODE_PRIVATE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ package chat.simplex.app.model
|
|||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Display
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
@@ -23,12 +25,18 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
const val ShowChatsAction: String = "chat.simplex.app.SHOW_CHATS"
|
||||||
|
|
||||||
// DO NOT change notification channel settings / names
|
// DO NOT change notification channel settings / names
|
||||||
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION"
|
const val CallChannel: String = "chat.simplex.app.CALL_NOTIFICATION_1"
|
||||||
const val LockScreenCallChannel: String = "chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION"
|
|
||||||
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
const val AcceptCallAction: String = "chat.simplex.app.ACCEPT_CALL"
|
||||||
|
const val RejectCallAction: String = "chat.simplex.app.REJECT_CALL"
|
||||||
const val CallNotificationId: Int = -1
|
const val CallNotificationId: Int = -1
|
||||||
|
|
||||||
|
private const val UserIdKey: String = "userId"
|
||||||
private const val ChatIdKey: String = "chatId"
|
private const val ChatIdKey: String = "chatId"
|
||||||
|
|
||||||
|
fun getUserIdFromIntent(intent: Intent?): Long? {
|
||||||
|
val userId = intent?.getLongExtra(UserIdKey, -1L)
|
||||||
|
return if (userId == -1L || userId == null) null else userId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
@@ -36,25 +44,26 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
private val msgNtfTimeoutMs = 30000L
|
private val msgNtfTimeoutMs = 30000L
|
||||||
|
|
||||||
init {
|
init {
|
||||||
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
if (manager.areNotificationsEnabled()) createNtfChannelsMaybeShowAlert()
|
||||||
manager.createNotificationChannel(NotificationChannel(LockScreenCallChannel, generalGetString(R.string.ntf_channel_calls_lockscreen), NotificationManager.IMPORTANCE_HIGH))
|
|
||||||
manager.createNotificationChannel(callNotificationChannel())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class NotificationAction {
|
enum class NotificationAction {
|
||||||
ACCEPT_CONTACT_REQUEST
|
ACCEPT_CONTACT_REQUEST
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun callNotificationChannel(): NotificationChannel {
|
private fun callNotificationChannel(channelId: String, channelName: String): NotificationChannel {
|
||||||
val callChannel = NotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls), NotificationManager.IMPORTANCE_HIGH)
|
val callChannel = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||||
val attrs = AudioAttributes.Builder()
|
val attrs = AudioAttributes.Builder()
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
|
||||||
.build()
|
.build()
|
||||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
Log.d(TAG,"callNotificationChannel sound: $soundUri")
|
||||||
callChannel.setSound(soundUri, attrs)
|
callChannel.setSound(soundUri, attrs)
|
||||||
callChannel.enableVibration(true)
|
callChannel.enableVibration(true)
|
||||||
|
// the numbers below are explained here: https://developer.android.com/reference/android/os/Vibrator
|
||||||
|
// (wait, vibration duration, wait till off, wait till on again = ringtone mp3 duration - vibration duration - ~50ms lost somewhere)
|
||||||
|
callChannel.vibrationPattern = longArrayOf(250, 250, 0, 2600)
|
||||||
return callChannel
|
return callChannel
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,8 +79,9 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyContactRequestReceived(cInfo: ChatInfo.ContactRequest) {
|
fun notifyContactRequestReceived(user: User, cInfo: ChatInfo.ContactRequest) {
|
||||||
notifyMessageReceived(
|
displayNotification(
|
||||||
|
user = user,
|
||||||
chatId = cInfo.id,
|
chatId = cInfo.id,
|
||||||
displayName = cInfo.displayName,
|
displayName = cInfo.displayName,
|
||||||
msgText = generalGetString(R.string.notification_new_contact_request),
|
msgText = generalGetString(R.string.notification_new_contact_request),
|
||||||
@@ -80,21 +90,22 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyContactConnected(contact: Contact) {
|
fun notifyContactConnected(user: User, contact: Contact) {
|
||||||
notifyMessageReceived(
|
displayNotification(
|
||||||
|
user = user,
|
||||||
chatId = contact.id,
|
chatId = contact.id,
|
||||||
displayName = contact.displayName,
|
displayName = contact.displayName,
|
||||||
msgText = generalGetString(R.string.notification_contact_connected)
|
msgText = generalGetString(R.string.notification_contact_connected)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
|
fun notifyMessageReceived(user: User, cInfo: ChatInfo, cItem: ChatItem) {
|
||||||
if (!cInfo.ntfsEnabled) return
|
if (!cInfo.ntfsEnabled) return
|
||||||
|
displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
||||||
notifyMessageReceived(chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun notifyMessageReceived(chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
fun displayNotification(user: User, chatId: String, displayName: String, msgText: String, image: String? = null, actions: List<NotificationAction> = emptyList()) {
|
||||||
|
if (!user.showNotifications) return
|
||||||
Log.d(TAG, "notifyMessageReceived $chatId")
|
Log.d(TAG, "notifyMessageReceived $chatId")
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
val recentNotification = (now - prevNtfTime.getOrDefault(chatId, 0) < msgNtfTimeoutMs)
|
||||||
@@ -119,13 +130,14 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
|
.setVibrate(if (actions.isEmpty()) null else longArrayOf(0, 250, 250, 250))
|
||||||
.setContentIntent(chatPendingIntent(OpenChatAction, chatId))
|
.setContentIntent(chatPendingIntent(OpenChatAction, user.userId, chatId))
|
||||||
.setSilent(if (actions.isEmpty()) recentNotification else false)
|
.setSilent(if (actions.isEmpty()) recentNotification else false)
|
||||||
|
|
||||||
for (action in actions) {
|
for (action in actions) {
|
||||||
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
|
val actionIntent = Intent(SimplexApp.context, NtfActionReceiver::class.java)
|
||||||
actionIntent.action = action.name
|
actionIntent.action = action.name
|
||||||
|
actionIntent.putExtra(UserIdKey, user.userId)
|
||||||
actionIntent.putExtra(ChatIdKey, chatId)
|
actionIntent.putExtra(ChatIdKey, chatId)
|
||||||
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
val actionPendingIntent: PendingIntent = PendingIntent.getBroadcast(SimplexApp.context, 0, actionIntent, flags)
|
||||||
val actionButton = when (action) {
|
val actionButton = when (action) {
|
||||||
@@ -140,7 +152,7 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
.setGroup(MessageGroup)
|
.setGroup(MessageGroup)
|
||||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||||
.setGroupSummary(true)
|
.setGroupSummary(true)
|
||||||
.setContentIntent(chatPendingIntent(ShowChatsAction))
|
.setContentIntent(chatPendingIntent(ShowChatsAction, null))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
@@ -151,24 +163,34 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
fun notifyCallInvitation(invitation: RcvCallInvitation) {
|
||||||
if (isAppOnForeground(context)) return
|
val keyguardManager = getKeyguardManager(context)
|
||||||
|
Log.d(TAG,
|
||||||
|
"notifyCallInvitation pre-requests: " +
|
||||||
|
"keyguard locked ${keyguardManager.isKeyguardLocked}, " +
|
||||||
|
"callOnLockScreen ${appPreferences.callOnLockScreen.get()}, " +
|
||||||
|
"onForeground ${SimplexApp.context.isAppOnForeground}"
|
||||||
|
)
|
||||||
|
if (SimplexApp.context.isAppOnForeground) return
|
||||||
val contactId = invitation.contact.id
|
val contactId = invitation.contact.id
|
||||||
Log.d(TAG, "notifyCallInvitation $contactId")
|
Log.d(TAG, "notifyCallInvitation $contactId")
|
||||||
val keyguardManager = getKeyguardManager(context)
|
|
||||||
val image = invitation.contact.image
|
val image = invitation.contact.image
|
||||||
|
val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||||
|
val screenOff = displayManager.displays.all { it.state != Display.STATE_ON }
|
||||||
var ntfBuilder =
|
var ntfBuilder =
|
||||||
if (keyguardManager.isDeviceLocked && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
if ((keyguardManager.isKeyguardLocked || screenOff) && appPreferences.callOnLockScreen.get() != CallOnLockScreen.DISABLE) {
|
||||||
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
val fullScreenIntent = Intent(context, IncomingCallActivity::class.java)
|
||||||
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, LockScreenCallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setFullScreenIntent(fullScreenPendingIntent, true)
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setSilent(true)
|
|
||||||
} else {
|
} else {
|
||||||
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
val soundUri = Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + context.packageName + "/" + R.raw.ring_once)
|
||||||
|
val fullScreenPendingIntent = PendingIntent.getActivity(context, 0, Intent(), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
NotificationCompat.Builder(context, CallChannel)
|
NotificationCompat.Builder(context, CallChannel)
|
||||||
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.contact.id))
|
.setContentIntent(chatPendingIntent(OpenChatAction, invitation.user.userId, invitation.contact.id))
|
||||||
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, contactId))
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.accept), chatPendingIntent(AcceptCallAction, invitation.user.userId, contactId))
|
||||||
|
.addAction(R.drawable.ntf_icon, generalGetString(R.string.reject), chatPendingIntent(RejectCallAction, invitation.user.userId, contactId, true))
|
||||||
|
.setFullScreenIntent(fullScreenPendingIntent, true)
|
||||||
.setSound(soundUri)
|
.setSound(soundUri)
|
||||||
}
|
}
|
||||||
val text = generalGetString(
|
val text = generalGetString(
|
||||||
@@ -197,8 +219,11 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
.setLargeIcon(largeIcon)
|
.setLargeIcon(largeIcon)
|
||||||
.setColor(0x88FFFF)
|
.setColor(0x88FFFF)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
val notification = ntfBuilder.build()
|
||||||
|
// This makes notification sound and vibration repeat endlessly
|
||||||
|
notification.flags = notification.flags or NotificationCompat.FLAG_INSISTENT
|
||||||
with(NotificationManagerCompat.from(context)) {
|
with(NotificationManagerCompat.from(context)) {
|
||||||
notify(CallNotificationId, ntfBuilder.build())
|
notify(CallNotificationId, notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,49 +231,80 @@ class NtfManager(val context: Context, private val appPreferences: AppPreference
|
|||||||
manager.cancel(CallNotificationId)
|
manager.cancel(CallNotificationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun hasNotificationsForChat(chatId: String): Boolean = manager.activeNotifications.any { it.id == chatId.hashCode() }
|
||||||
|
|
||||||
private fun hideSecrets(cItem: ChatItem) : String {
|
private fun hideSecrets(cItem: ChatItem) : String {
|
||||||
val md = cItem.formattedText
|
val md = cItem.formattedText
|
||||||
return if (md == null) {
|
return if (md != null) {
|
||||||
if (cItem.content.text != "") {
|
|
||||||
cItem.content.text
|
|
||||||
} else {
|
|
||||||
cItem.file?.fileName ?: ""
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var res = ""
|
var res = ""
|
||||||
for (ft in md) {
|
for (ft in md) {
|
||||||
res += if (ft.format is Format.Secret) "..." else ft.text
|
res += if (ft.format is Format.Secret) "..." else ft.text
|
||||||
}
|
}
|
||||||
res
|
res
|
||||||
|
} else {
|
||||||
|
cItem.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun chatPendingIntent(intentAction: String, chatId: String? = null): PendingIntent {
|
private fun chatPendingIntent(intentAction: String, userId: Long?, chatId: String? = null, broadcast: Boolean = false): PendingIntent {
|
||||||
Log.d(TAG, "chatPendingIntent for $intentAction")
|
Log.d(TAG, "chatPendingIntent for $intentAction")
|
||||||
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
|
||||||
var intent = Intent(context, MainActivity::class.java)
|
var intent = Intent(context, if (!broadcast) MainActivity::class.java else NtfActionReceiver::class.java)
|
||||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
.setAction(intentAction)
|
.setAction(intentAction)
|
||||||
|
.putExtra(UserIdKey, userId)
|
||||||
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
if (chatId != null) intent = intent.putExtra(ChatIdKey, chatId)
|
||||||
return TaskStackBuilder.create(context).run {
|
return if (!broadcast) {
|
||||||
addNextIntentWithParentStack(intent)
|
TaskStackBuilder.create(context).run {
|
||||||
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
addNextIntentWithParentStack(intent)
|
||||||
|
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PendingIntent.getBroadcast(SimplexApp.context, uniqueInt, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates notifications channels. On Android 13+ calling it for the first time will trigger system alert,
|
||||||
|
* The alert asks a user to allow or disallow to show notifications for the app. That's why it should be called only when the user
|
||||||
|
* already saw such alert or when you want to trigger showing the alert.
|
||||||
|
* On the first app launch the channels will be created after user profile is created. Subsequent calls will create new channels and delete
|
||||||
|
* old ones if needed
|
||||||
|
* */
|
||||||
|
fun createNtfChannelsMaybeShowAlert() {
|
||||||
|
manager.createNotificationChannel(NotificationChannel(MessageChannel, generalGetString(R.string.ntf_channel_messages), NotificationManager.IMPORTANCE_HIGH))
|
||||||
|
manager.createNotificationChannel(callNotificationChannel(CallChannel, generalGetString(R.string.ntf_channel_calls)))
|
||||||
|
// Remove old channels since they can't be edited
|
||||||
|
manager.deleteNotificationChannel("chat.simplex.app.CALL_NOTIFICATION")
|
||||||
|
manager.deleteNotificationChannel("chat.simplex.app.LOCK_SCREEN_CALL_NOTIFICATION")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes every action specified by [NotificationCompat.Builder.addAction] that comes with [NotificationAction]
|
* Processes every action specified by [NotificationCompat.Builder.addAction] that comes with [NotificationAction]
|
||||||
* and [ChatInfo.id] as [ChatIdKey] in extra
|
* and [ChatInfo.id] as [ChatIdKey] in extra
|
||||||
* */
|
* */
|
||||||
class NtfActionReceiver: BroadcastReceiver() {
|
class NtfActionReceiver: BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val userId = getUserIdFromIntent(intent)
|
||||||
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
val chatId = intent?.getStringExtra(ChatIdKey) ?: return
|
||||||
val cInfo = SimplexApp.context.chatModel.getChat(chatId)?.chatInfo
|
val m = SimplexApp.context.chatModel
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
NotificationAction.ACCEPT_CONTACT_REQUEST.name -> {
|
||||||
if (cInfo !is ChatInfo.ContactRequest) return
|
val isCurrentUser = m.currentUser.value?.userId == userId
|
||||||
acceptContactRequest(cInfo, SimplexApp.context.chatModel)
|
val cInfo: ChatInfo.ContactRequest? = if (isCurrentUser) {
|
||||||
SimplexApp.context.chatModel.controller.ntfManager.cancelNotificationsForChat(chatId)
|
(m.getChat(chatId)?.chatInfo as? ChatInfo.ContactRequest) ?: return
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val apiId = chatId.replace("<@", "").toLongOrNull() ?: return
|
||||||
|
acceptContactRequest(apiId, cInfo, isCurrentUser, m)
|
||||||
|
m.controller.ntfManager.cancelNotificationsForChat(chatId)
|
||||||
|
}
|
||||||
|
RejectCallAction -> {
|
||||||
|
val invitation = m.callInvitations[chatId]
|
||||||
|
if (invitation != null) {
|
||||||
|
m.callManager.endCall(invitation = invitation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
Log.e(TAG, "Unknown action. Make sure you provide action from NotificationAction enum")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ val MessagePreviewDark = Color(179, 175, 174, 255)
|
|||||||
val MessagePreviewLight = Color(49, 45, 44, 255)
|
val MessagePreviewLight = Color(49, 45, 44, 255)
|
||||||
val ToolbarLight = Color(220, 220, 220, 12)
|
val ToolbarLight = Color(220, 220, 220, 12)
|
||||||
val ToolbarDark = Color(80, 80, 80, 12)
|
val ToolbarDark = Color(80, 80, 80, 12)
|
||||||
val SettingsBackgroundLight = Color(220, 216, 215, 90)
|
|
||||||
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
val SettingsSecondaryLight = Color(200, 196, 195, 90)
|
||||||
val GroupDark = Color(80, 80, 80, 60)
|
val GroupDark = Color(80, 80, 80, 60)
|
||||||
val IncomingCallLight = Color(239, 237, 236, 255)
|
val IncomingCallLight = Color(239, 237, 236, 255)
|
||||||
@@ -27,3 +26,4 @@ val WarningOrange = Color(255, 127, 0, 255)
|
|||||||
val WarningYellow = Color(255, 192, 0, 255)
|
val WarningYellow = Color(255, 192, 0, 255)
|
||||||
val FileLight = Color(183, 190, 199, 255)
|
val FileLight = Color(183, 190, 199, 255)
|
||||||
val FileDark = Color(101, 101, 106, 255)
|
val FileDark = Color(101, 101, 106, 255)
|
||||||
|
val MenuTextColorDark = Color.White.copy(alpha = 0.8f)
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ val Shapes = Shapes(
|
|||||||
small = RoundedCornerShape(4.dp),
|
small = RoundedCornerShape(4.dp),
|
||||||
medium = RoundedCornerShape(4.dp),
|
medium = RoundedCornerShape(4.dp),
|
||||||
large = RoundedCornerShape(0.dp)
|
large = RoundedCornerShape(0.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,55 +2,248 @@ package chat.simplex.app.ui.theme
|
|||||||
|
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
enum class DefaultTheme {
|
enum class DefaultTheme {
|
||||||
SYSTEM, DARK, LIGHT
|
SYSTEM, LIGHT, DARK, SIMPLEX;
|
||||||
|
|
||||||
|
// Call it only with base theme, not SYSTEM
|
||||||
|
fun hasChangedAnyColor(colors: Colors, appColors: AppColors): Boolean {
|
||||||
|
val palette = when (this) {
|
||||||
|
SYSTEM -> return false
|
||||||
|
LIGHT -> LightColorPalette
|
||||||
|
DARK -> DarkColorPalette
|
||||||
|
SIMPLEX -> SimplexColorPalette
|
||||||
|
}
|
||||||
|
val appPalette = when (this) {
|
||||||
|
SYSTEM -> return false
|
||||||
|
LIGHT -> LightColorPaletteApp
|
||||||
|
DARK -> DarkColorPaletteApp
|
||||||
|
SIMPLEX -> SimplexColorPaletteApp
|
||||||
|
}
|
||||||
|
return colors.primary != palette.primary ||
|
||||||
|
colors.primaryVariant != palette.primaryVariant ||
|
||||||
|
colors.secondary != palette.secondary ||
|
||||||
|
colors.secondaryVariant != palette.secondaryVariant ||
|
||||||
|
colors.background != palette.background ||
|
||||||
|
colors.surface != palette.surface ||
|
||||||
|
appColors != appPalette
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val DEFAULT_PADDING = 16.dp
|
data class AppColors(
|
||||||
|
val title: Color,
|
||||||
|
val sentMessage: Color,
|
||||||
|
val receivedMessage: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class ThemeColor {
|
||||||
|
PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, RECEIVED_MESSAGE;
|
||||||
|
|
||||||
|
fun fromColors(colors: Colors, appColors: AppColors): Color {
|
||||||
|
return when (this) {
|
||||||
|
PRIMARY -> colors.primary
|
||||||
|
PRIMARY_VARIANT -> colors.primaryVariant
|
||||||
|
SECONDARY -> colors.secondary
|
||||||
|
SECONDARY_VARIANT -> colors.secondaryVariant
|
||||||
|
BACKGROUND -> colors.background
|
||||||
|
SURFACE -> colors.surface
|
||||||
|
TITLE -> appColors.title
|
||||||
|
SENT_MESSAGE -> appColors.sentMessage
|
||||||
|
RECEIVED_MESSAGE -> appColors.receivedMessage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val text: String
|
||||||
|
get() = when (this) {
|
||||||
|
PRIMARY -> generalGetString(R.string.color_primary)
|
||||||
|
PRIMARY_VARIANT -> generalGetString(R.string.color_primary_variant)
|
||||||
|
SECONDARY -> generalGetString(R.string.color_secondary)
|
||||||
|
SECONDARY_VARIANT -> generalGetString(R.string.color_secondary_variant)
|
||||||
|
BACKGROUND -> generalGetString(R.string.color_background)
|
||||||
|
SURFACE -> generalGetString(R.string.color_surface)
|
||||||
|
TITLE -> generalGetString(R.string.color_title)
|
||||||
|
SENT_MESSAGE -> generalGetString(R.string.color_sent_message)
|
||||||
|
RECEIVED_MESSAGE -> generalGetString(R.string.color_received_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeColors(
|
||||||
|
@SerialName("accent")
|
||||||
|
val primary: String? = null,
|
||||||
|
@SerialName("accentVariant")
|
||||||
|
val primaryVariant: String? = null,
|
||||||
|
val secondary: String? = null,
|
||||||
|
val secondaryVariant: String? = null,
|
||||||
|
val background: String? = null,
|
||||||
|
@SerialName("menus")
|
||||||
|
val surface: String? = null,
|
||||||
|
val title: String? = null,
|
||||||
|
val sentMessage: String? = null,
|
||||||
|
val receivedMessage: String? = null,
|
||||||
|
) {
|
||||||
|
fun toColors(base: DefaultTheme): Colors {
|
||||||
|
val baseColors = when (base) {
|
||||||
|
DefaultTheme.LIGHT -> LightColorPalette
|
||||||
|
DefaultTheme.DARK -> DarkColorPalette
|
||||||
|
DefaultTheme.SIMPLEX -> SimplexColorPalette
|
||||||
|
// shouldn't be here
|
||||||
|
DefaultTheme.SYSTEM -> LightColorPalette
|
||||||
|
}
|
||||||
|
return baseColors.copy(
|
||||||
|
primary = primary?.colorFromReadableHex() ?: baseColors.primary,
|
||||||
|
primaryVariant = primaryVariant?.colorFromReadableHex() ?: baseColors.primaryVariant,
|
||||||
|
secondary = secondary?.colorFromReadableHex() ?: baseColors.secondary,
|
||||||
|
secondaryVariant = secondaryVariant?.colorFromReadableHex() ?: baseColors.secondaryVariant,
|
||||||
|
background = background?.colorFromReadableHex() ?: baseColors.background,
|
||||||
|
surface = surface?.colorFromReadableHex() ?: baseColors.surface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toAppColors(base: DefaultTheme): AppColors {
|
||||||
|
val baseColors = when (base) {
|
||||||
|
DefaultTheme.LIGHT -> LightColorPaletteApp
|
||||||
|
DefaultTheme.DARK -> DarkColorPaletteApp
|
||||||
|
DefaultTheme.SIMPLEX -> SimplexColorPaletteApp
|
||||||
|
// shouldn't be here
|
||||||
|
DefaultTheme.SYSTEM -> LightColorPaletteApp
|
||||||
|
}
|
||||||
|
return baseColors.copy(
|
||||||
|
title = title?.colorFromReadableHex() ?: baseColors.title,
|
||||||
|
sentMessage = sentMessage?.colorFromReadableHex() ?: baseColors.sentMessage,
|
||||||
|
receivedMessage = receivedMessage?.colorFromReadableHex() ?: baseColors.receivedMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.colorFromReadableHex(): Color =
|
||||||
|
Color(this.replace("#", "").toLongOrNull(16) ?: Color.White.toArgb().toLong())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeOverrides (
|
||||||
|
val base: DefaultTheme,
|
||||||
|
val colors: ThemeColors
|
||||||
|
) {
|
||||||
|
fun withUpdatedColor(name: ThemeColor, color: String): ThemeOverrides {
|
||||||
|
return copy(colors = when (name) {
|
||||||
|
ThemeColor.PRIMARY -> colors.copy(primary = color)
|
||||||
|
ThemeColor.PRIMARY_VARIANT -> colors.copy(primaryVariant = color)
|
||||||
|
ThemeColor.SECONDARY -> colors.copy(secondary = color)
|
||||||
|
ThemeColor.SECONDARY_VARIANT -> colors.copy(secondaryVariant = color)
|
||||||
|
ThemeColor.BACKGROUND -> colors.copy(background = color)
|
||||||
|
ThemeColor.SURFACE -> colors.copy(surface = color)
|
||||||
|
ThemeColor.TITLE -> colors.copy(title = color)
|
||||||
|
ThemeColor.SENT_MESSAGE -> colors.copy(sentMessage = color)
|
||||||
|
ThemeColor.RECEIVED_MESSAGE -> colors.copy(receivedMessage = color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ThemeData (val colors: ThemeColors)
|
||||||
|
|
||||||
|
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
|
||||||
|
return if (baseTheme == DefaultTheme.SIMPLEX) {
|
||||||
|
this.background(brush = Brush.linearGradient(
|
||||||
|
listOf(
|
||||||
|
CurrentColors.value.colors.background.darker(0.4f),
|
||||||
|
CurrentColors.value.colors.background.lighter(0.4f)
|
||||||
|
),
|
||||||
|
Offset(0f, Float.POSITIVE_INFINITY),
|
||||||
|
Offset(Float.POSITIVE_INFINITY, 0f)
|
||||||
|
), shape = shape)
|
||||||
|
} else {
|
||||||
|
this.background(color = CurrentColors.value.colors.background, shape = shape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val DEFAULT_PADDING = 20.dp
|
||||||
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
val DEFAULT_SPACE_AFTER_ICON = 4.dp
|
||||||
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
|
||||||
|
val DEFAULT_BOTTOM_PADDING = 48.dp
|
||||||
|
val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp
|
||||||
|
|
||||||
val DarkColorPalette = darkColors(
|
val DarkColorPalette = darkColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
primaryVariant = SimplexGreen,
|
primaryVariant = SimplexBlue,
|
||||||
secondary = DarkGray,
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = DarkGray,
|
||||||
// background = Color.Black,
|
// background = Color.Black,
|
||||||
// surface = Color.Black,
|
surface = Color(0xFF222222),
|
||||||
// background = Color(0xFF121212),
|
// background = Color(0xFF121212),
|
||||||
// surface = Color(0xFF121212),
|
// surface = Color(0xFF121212),
|
||||||
// error = Color(0xFFCF6679),
|
error = Color.Red,
|
||||||
onBackground = Color(0xFFFFFBFA),
|
onBackground = Color(0xFFFFFBFA),
|
||||||
onSurface = Color(0xFFFFFBFA),
|
onSurface = Color(0xFFFFFBFA),
|
||||||
// onError: Color = Color.Black,
|
// onError: Color = Color.Black,
|
||||||
)
|
)
|
||||||
|
val DarkColorPaletteApp = AppColors(
|
||||||
|
title = SimplexBlue,
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
val LightColorPalette = lightColors(
|
val LightColorPalette = lightColors(
|
||||||
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files
|
||||||
primaryVariant = SimplexGreen,
|
primaryVariant = SimplexBlue,
|
||||||
secondary = LightGray,
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = LightGray,
|
||||||
|
error = Color.Red,
|
||||||
// background = Color.White,
|
// background = Color.White,
|
||||||
// surface = Color.White
|
surface = Color.White,
|
||||||
// onPrimary = Color.White,
|
// onPrimary = Color.White,
|
||||||
// onSecondary = Color.Black,
|
// onSecondary = Color.Black,
|
||||||
// onBackground = Color.Black,
|
// onBackground = Color.Black,
|
||||||
// onSurface = Color.Black,
|
// onSurface = Color.Black,
|
||||||
)
|
)
|
||||||
|
val LightColorPaletteApp = AppColors(
|
||||||
|
title = SimplexBlue,
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
val CurrentColors: MutableStateFlow<Pair<Colors, DefaultTheme>> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
val SimplexColorPalette = darkColors(
|
||||||
|
primary = Color(0xFF70F0F9), // If this value changes also need to update #0088ff in string resource files
|
||||||
|
primaryVariant = Color(0xFF1298A5),
|
||||||
|
secondary = HighOrLowlight,
|
||||||
|
secondaryVariant = Color(0xFF2C464D),
|
||||||
|
background = Color(0xFF111528),
|
||||||
|
// surface = Color.Black,
|
||||||
|
// background = Color(0xFF121212),
|
||||||
|
surface = Color(0xFF121C37),
|
||||||
|
error = Color.Red,
|
||||||
|
// onBackground = Color(0xFFFFFBFA),
|
||||||
|
// onSurface = Color(0xFFFFFBFA),
|
||||||
|
// onError: Color = Color.Black,
|
||||||
|
)
|
||||||
|
val SimplexColorPaletteApp = AppColors(
|
||||||
|
title = Color(0xFF267BE5),
|
||||||
|
sentMessage = Color(0x1E45B8FF),
|
||||||
|
receivedMessage = Color(0x20B1B0B5)
|
||||||
|
)
|
||||||
|
|
||||||
|
val CurrentColors: MutableStateFlow<ThemeManager.ActiveTheme> = MutableStateFlow(ThemeManager.currentColors(isInNightMode()))
|
||||||
|
|
||||||
// Non-@Composable implementation
|
// Non-@Composable implementation
|
||||||
private fun isInNightMode() =
|
private fun isInNightMode() =
|
||||||
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
(SimplexApp.context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager).nightMode == UiModeManager.MODE_NIGHT_YES
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.first.isLight
|
fun isInDarkTheme(): Boolean = !CurrentColors.collectAsState().value.colors.isLight
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
||||||
@@ -61,16 +254,16 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
val systemDark = isSystemInDarkTheme()
|
val systemDark = isSystemInDarkTheme()
|
||||||
LaunchedEffect(systemDark) {
|
LaunchedEffect(systemDark) {
|
||||||
if (CurrentColors.value.second == DefaultTheme.SYSTEM && CurrentColors.value.first.isLight == systemDark) {
|
if (SimplexApp.context.chatModel.controller.appPrefs.currentTheme.get() == DefaultTheme.SYSTEM.name && CurrentColors.value.colors.isLight == systemDark) {
|
||||||
// Change active colors from light to dark and back based on system theme
|
// Change active colors from light to dark and back based on system theme
|
||||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
ThemeManager.applyTheme(DefaultTheme.SYSTEM.name, systemDark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val theme by CurrentColors.collectAsState()
|
val theme by CurrentColors.collectAsState()
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colors = theme.first,
|
colors = theme.colors,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
shapes = Shapes,
|
shapes = Shapes,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,22 +7,56 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
import okhttp3.internal.toHexString
|
||||||
|
|
||||||
object ThemeManager {
|
object ThemeManager {
|
||||||
private val appPrefs: AppPreferences by lazy {
|
private val appPrefs: AppPreferences by lazy {
|
||||||
AppPreferences(SimplexApp.context)
|
SimplexApp.context.chatModel.controller.appPrefs
|
||||||
}
|
}
|
||||||
|
|
||||||
fun currentColors(darkForSystemTheme: Boolean): Pair<Colors, DefaultTheme> {
|
data class ActiveTheme(val name: String, val base: DefaultTheme, val colors: Colors, val appColors: AppColors)
|
||||||
val theme = appPrefs.currentTheme.get()!!
|
|
||||||
val systemThemeColors = if (darkForSystemTheme) DarkColorPalette else LightColorPalette
|
private fun systemDarkThemeColors(): Pair<Colors, DefaultTheme> = when (appPrefs.systemDarkTheme.get()) {
|
||||||
val res = when (theme) {
|
DefaultTheme.DARK.name -> DarkColorPalette to DefaultTheme.DARK
|
||||||
DefaultTheme.SYSTEM.name -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
DefaultTheme.SIMPLEX.name -> SimplexColorPalette to DefaultTheme.SIMPLEX
|
||||||
DefaultTheme.DARK.name -> Pair(DarkColorPalette, DefaultTheme.DARK)
|
else -> SimplexColorPalette to DefaultTheme.SIMPLEX
|
||||||
DefaultTheme.LIGHT.name -> Pair(LightColorPalette, DefaultTheme.LIGHT)
|
}
|
||||||
else -> Pair(systemThemeColors, DefaultTheme.SYSTEM)
|
|
||||||
|
fun currentColors(darkForSystemTheme: Boolean): ActiveTheme {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val themeOverrides = appPrefs.themeOverrides.get()
|
||||||
|
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
}
|
}
|
||||||
return res.copy(first = res.first.copy(primary = Color(appPrefs.primaryColor.get())))
|
val theme = themeOverrides[nonSystemThemeName]
|
||||||
|
val baseTheme = when (nonSystemThemeName) {
|
||||||
|
DefaultTheme.LIGHT.name -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
|
||||||
|
DefaultTheme.DARK.name -> Triple(DefaultTheme.DARK, DarkColorPalette, DarkColorPaletteApp)
|
||||||
|
DefaultTheme.SIMPLEX.name -> Triple(DefaultTheme.SIMPLEX, SimplexColorPalette, SimplexColorPaletteApp)
|
||||||
|
else -> Triple(DefaultTheme.LIGHT, LightColorPalette, LightColorPaletteApp)
|
||||||
|
}
|
||||||
|
if (theme == null) {
|
||||||
|
return ActiveTheme(themeName, baseTheme.first, baseTheme.second, baseTheme.third)
|
||||||
|
}
|
||||||
|
return ActiveTheme(themeName, baseTheme.first, theme.colors.toColors(theme.base), theme.colors.toAppColors(theme.base))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentThemeData(darkForSystemTheme: Boolean): ThemeData {
|
||||||
|
val t = currentColors(darkForSystemTheme)
|
||||||
|
return ThemeData(colors = ThemeColors(
|
||||||
|
primary = t.colors.primary.toReadableHex(),
|
||||||
|
primaryVariant = t.colors.primaryVariant.toReadableHex(),
|
||||||
|
secondary = t.colors.secondary.toReadableHex(),
|
||||||
|
secondaryVariant = t.colors.secondaryVariant.toReadableHex(),
|
||||||
|
background = t.colors.background.toReadableHex(),
|
||||||
|
surface = t.colors.surface.toReadableHex(),
|
||||||
|
title = t.appColors.title.toReadableHex(),
|
||||||
|
sentMessage = t.appColors.sentMessage.toReadableHex(),
|
||||||
|
receivedMessage = t.appColors.receivedMessage.toReadableHex()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// colors, default theme enum, localized name of theme
|
// colors, default theme enum, localized name of theme
|
||||||
@@ -30,7 +64,7 @@ object ThemeManager {
|
|||||||
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
val allThemes = ArrayList<Triple<Colors, DefaultTheme, String>>()
|
||||||
allThemes.add(
|
allThemes.add(
|
||||||
Triple(
|
Triple(
|
||||||
if (darkForSystemTheme) DarkColorPalette else LightColorPalette,
|
if (darkForSystemTheme) systemDarkThemeColors().first else LightColorPalette,
|
||||||
DefaultTheme.SYSTEM,
|
DefaultTheme.SYSTEM,
|
||||||
generalGetString(R.string.theme_system)
|
generalGetString(R.string.theme_system)
|
||||||
)
|
)
|
||||||
@@ -49,16 +83,72 @@ object ThemeManager {
|
|||||||
generalGetString(R.string.theme_dark)
|
generalGetString(R.string.theme_dark)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
allThemes.add(
|
||||||
|
Triple(
|
||||||
|
SimplexColorPalette,
|
||||||
|
DefaultTheme.SIMPLEX,
|
||||||
|
generalGetString(R.string.theme_simplex)
|
||||||
|
)
|
||||||
|
)
|
||||||
return allThemes
|
return allThemes
|
||||||
}
|
}
|
||||||
|
|
||||||
fun applyTheme(name: String, darkForSystemTheme: Boolean) {
|
fun applyTheme(theme: String, darkForSystemTheme: Boolean) {
|
||||||
appPrefs.currentTheme.set(name)
|
appPrefs.currentTheme.set(theme)
|
||||||
CurrentColors.value = currentColors(darkForSystemTheme)
|
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveAndApplyPrimaryColor(color: Color) {
|
fun changeDarkTheme(theme: String, darkForSystemTheme: Boolean) {
|
||||||
appPrefs.primaryColor.set(color.toArgb())
|
appPrefs.systemDarkTheme.set(theme)
|
||||||
CurrentColors.value = currentColors(!CurrentColors.value.first.isLight)
|
CurrentColors.value = currentColors(darkForSystemTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndApplyThemeColor(name: ThemeColor, color: Color? = null, darkForSystemTheme: Boolean) {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
|
}
|
||||||
|
var colorToSet = color
|
||||||
|
if (colorToSet == null) {
|
||||||
|
// Setting default color from a base theme
|
||||||
|
colorToSet = when(nonSystemThemeName) {
|
||||||
|
DefaultTheme.LIGHT.name -> name.fromColors(LightColorPalette, LightColorPaletteApp)
|
||||||
|
DefaultTheme.DARK.name -> name.fromColors(DarkColorPalette, DarkColorPaletteApp)
|
||||||
|
DefaultTheme.SIMPLEX.name -> name.fromColors(SimplexColorPalette, SimplexColorPaletteApp)
|
||||||
|
// Will not be here
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[nonSystemThemeName] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
||||||
|
overrides[nonSystemThemeName] = prevValue.withUpdatedColor(name, colorToSet.toReadableHex())
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndApplyThemeData(name: String, theme: ThemeData, darkForSystemTheme: Boolean) {
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[name] ?: ThemeOverrides(base = CurrentColors.value.base, colors = ThemeColors())
|
||||||
|
overrides[name] = prevValue.copy(colors = theme.colors)
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetAllThemeColors(darkForSystemTheme: Boolean) {
|
||||||
|
val themeName = appPrefs.currentTheme.get()!!
|
||||||
|
val nonSystemThemeName = if (themeName != DefaultTheme.SYSTEM.name) {
|
||||||
|
themeName
|
||||||
|
} else {
|
||||||
|
if (darkForSystemTheme) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.name
|
||||||
|
}
|
||||||
|
val overrides = appPrefs.themeOverrides.get().toMutableMap()
|
||||||
|
val prevValue = overrides[nonSystemThemeName] ?: return
|
||||||
|
overrides[nonSystemThemeName] = prevValue.copy(colors = ThemeColors())
|
||||||
|
appPrefs.themeOverrides.set(overrides)
|
||||||
|
CurrentColors.value = currentColors(!CurrentColors.value.colors.isLight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Color.toReadableHex(): String = "#" + toArgb().toHexString()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package chat.simplex.app.views
|
package chat.simplex.app.views
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Surface
|
import androidx.compose.material.Surface
|
||||||
@@ -11,8 +10,8 @@ import androidx.compose.ui.Modifier
|
|||||||
fun SplashView() {
|
fun SplashView() {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.fillMaxSize(),
|
||||||
.fillMaxSize()
|
color = MaterialTheme.colors.background
|
||||||
) {
|
) {
|
||||||
// Image(
|
// Image(
|
||||||
// painter = painterResource(R.drawable.logo),
|
// painter = painterResource(R.drawable.logo),
|
||||||
|
|||||||
@@ -1,29 +1,21 @@
|
|||||||
package chat.simplex.app.views
|
package chat.simplex.app.views
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.SystemClock
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
import chat.simplex.app.R
|
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.*
|
import chat.simplex.app.views.chat.*
|
||||||
@@ -31,70 +23,18 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import com.google.accompanist.insets.ProvideWindowInsets
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
|
|
||||||
private val lastSuccessfulAuth: MutableState<Long?> = mutableStateOf(null)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
|
||||||
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
|
||||||
val lastSuccessfulAuth = remember { lastSuccessfulAuth }
|
|
||||||
BackHandler(onBack = {
|
BackHandler(onBack = {
|
||||||
lastSuccessfulAuth.value = null
|
|
||||||
close()
|
close()
|
||||||
})
|
})
|
||||||
val authorized = remember { !chatModel.controller.appPrefs.performLA.get() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
LaunchedEffect(lastSuccessfulAuth.value) {
|
|
||||||
if (!authorized && !authorizedPreviously(lastSuccessfulAuth)) {
|
|
||||||
runAuth(lastSuccessfulAuth, context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (authorized || authorizedPreviously(lastSuccessfulAuth)) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
// Update auth each time user visits this screen in authenticated state just to prolong authorized time
|
|
||||||
lastSuccessfulAuth.value = SystemClock.elapsedRealtime()
|
|
||||||
}
|
|
||||||
TerminalLayout(
|
TerminalLayout(
|
||||||
chatModel.terminalItems,
|
remember { chatModel.terminalItems },
|
||||||
composeState,
|
composeState,
|
||||||
sendCommand = { sendCommand(chatModel, composeState) },
|
sendCommand = { sendCommand(chatModel, composeState) },
|
||||||
close
|
close
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Surface(Modifier.fillMaxSize()) {
|
|
||||||
Column(Modifier.background(MaterialTheme.colors.background)) {
|
|
||||||
CloseSheetBar(close)
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
SimpleButton(
|
|
||||||
stringResource(R.string.auth_unlock),
|
|
||||||
icon = Icons.Outlined.Lock,
|
|
||||||
click = {
|
|
||||||
runAuth(lastSuccessfulAuth, context)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun authorizedPreviously(lastSuccessfulAuth: State<Long?>): Boolean =
|
|
||||||
lastSuccessfulAuth.value?.let { SystemClock.elapsedRealtime() - it < 30_000 } ?: false
|
|
||||||
|
|
||||||
private fun runAuth(lastSuccessfulAuth: MutableState<Long?>, context: Context) {
|
|
||||||
authenticate(
|
|
||||||
generalGetString(R.string.auth_open_chat_console),
|
|
||||||
generalGetString(R.string.auth_log_in_using_credential),
|
|
||||||
context as FragmentActivity,
|
|
||||||
completed = { laResult ->
|
|
||||||
lastSuccessfulAuth.value = when (laResult) {
|
|
||||||
LAResult.Success, LAResult.Unavailable -> SystemClock.elapsedRealtime()
|
|
||||||
is LAResult.Error, LAResult.Failed -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
|
private fun sendCommand(chatModel: ChatModel, composeState: MutableState<ComposeState>) {
|
||||||
@@ -102,9 +42,9 @@ private fun sendCommand(chatModel: ChatModel, composeState: MutableState<Compose
|
|||||||
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
val prefPerformLA = chatModel.controller.appPrefs.performLA.get()
|
||||||
val s = composeState.value.message
|
val s = composeState.value.message
|
||||||
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
if (s.startsWith("/sql") && (!prefPerformLA || !developerTools)) {
|
||||||
val resp = CR.ChatCmdError(ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
val resp = CR.ChatCmdError(null, ChatError.ChatErrorChat(ChatErrorType.СommandError("Failed reading: empty")))
|
||||||
chatModel.terminalItems.add(TerminalItem.cmd(CC.Console(s)))
|
chatModel.addTerminalItem(TerminalItem.cmd(CC.Console(s)))
|
||||||
chatModel.terminalItems.add(TerminalItem.resp(resp))
|
chatModel.addTerminalItem(TerminalItem.resp(resp))
|
||||||
composeState.value = ComposeState(useLinkPreviews = false)
|
composeState.value = ComposeState(useLinkPreviews = false)
|
||||||
} else {
|
} else {
|
||||||
withApi {
|
withApi {
|
||||||
@@ -135,7 +75,23 @@ fun TerminalLayout(
|
|||||||
topBar = { CloseSheetBar(close) },
|
topBar = { CloseSheetBar(close) },
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||||
SendMsgView(composeState, sendCommand, ::onMessageChange, textStyle)
|
SendMsgView(
|
||||||
|
composeState = composeState,
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
|
isDirectChat = false,
|
||||||
|
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
|
allowedVoiceByPrefs = false,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
|
allowVoiceToContact = {},
|
||||||
|
sendMessage = sendCommand,
|
||||||
|
sendLiveMessage = null,
|
||||||
|
updateLiveMessage = null,
|
||||||
|
onMessageChange = ::onMessageChange,
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.navigationBarsWithImePadding()
|
modifier = Modifier.navigationBarsWithImePadding()
|
||||||
@@ -144,7 +100,7 @@ fun TerminalLayout(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(contentPadding)
|
.padding(contentPadding)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
TerminalLog(terminalItems)
|
TerminalLog(terminalItems)
|
||||||
}
|
}
|
||||||
@@ -160,7 +116,8 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
|||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||||
}
|
}
|
||||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed() } }
|
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||||
|
val context = LocalContext.current
|
||||||
LazyColumn(state = listState, reverseLayout = true) {
|
LazyColumn(state = listState, reverseLayout = true) {
|
||||||
items(reversedTerminalItems) { item ->
|
items(reversedTerminalItems) { item ->
|
||||||
Text(
|
Text(
|
||||||
@@ -171,7 +128,7 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable {
|
.clickable {
|
||||||
ModalManager.shared.showModal {
|
ModalManager.shared.showModal(endButtons = { ShareButton { shareText(context, item.details) } }) {
|
||||||
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
Text(item.details, modifier = Modifier.padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,93 +6,107 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.MaterialTheme.colors
|
||||||
import androidx.compose.material.icons.outlined.ArrowBackIosNew
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowForwardIos
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.focus.*
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import androidx.compose.ui.text.style.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexService
|
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
import chat.simplex.app.model.Profile
|
import chat.simplex.app.model.Profile
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.AppBarTitle
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.helpers.withApi
|
|
||||||
import chat.simplex.app.views.onboarding.OnboardingStage
|
import chat.simplex.app.views.onboarding.OnboardingStage
|
||||||
import chat.simplex.app.views.onboarding.ReadableText
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
fun isValidDisplayName(name: String) : Boolean {
|
fun isValidDisplayName(name: String) : Boolean {
|
||||||
return (name.firstOrNull { it.isWhitespace() }) == null
|
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CreateProfilePanel(chatModel: ChatModel) {
|
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) {
|
||||||
val displayName = remember { mutableStateOf("") }
|
val displayName = rememberSaveable { mutableStateOf("") }
|
||||||
val fullName = remember { mutableStateOf("") }
|
val fullName = rememberSaveable { mutableStateOf("") }
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
Surface(Modifier.background(MaterialTheme.colors.onBackground)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.create_profile), false)
|
/*CloseSheetBar(close = {
|
||||||
ReadableText(R.string.your_profile_is_stored_on_your_device)
|
if (chatModel.users.isEmpty()) {
|
||||||
ReadableText(R.string.profile_is_only_shared_with_your_contacts)
|
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
|
||||||
Spacer(Modifier.height(10.dp))
|
} else {
|
||||||
Text(
|
close()
|
||||||
stringResource(R.string.display_name),
|
}
|
||||||
style = MaterialTheme.typography.h6,
|
})*/
|
||||||
modifier = Modifier.padding(bottom = 3.dp)
|
Column(Modifier.padding(horizontal = DEFAULT_PADDING * 1f)) {
|
||||||
)
|
AppBarTitle(stringResource(R.string.create_profile))
|
||||||
ProfileNameField(displayName, focusRequester)
|
ReadableText(R.string.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues())
|
||||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
ReadableText(R.string.profile_is_only_shared_with_your_contacts, TextAlign.Center)
|
||||||
Text(
|
Spacer(Modifier.height(DEFAULT_PADDING * 1.5f))
|
||||||
errorText,
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
fontSize = 15.sp,
|
Text(
|
||||||
color = MaterialTheme.colors.error
|
stringResource(R.string.display_name),
|
||||||
)
|
fontSize = 16.sp
|
||||||
Spacer(Modifier.height(3.dp))
|
)
|
||||||
Text(
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
stringResource(R.string.full_name_optional__prompt),
|
Text(
|
||||||
style = MaterialTheme.typography.h6,
|
stringResource(R.string.no_spaces),
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
fontSize = 16.sp,
|
||||||
)
|
color = Color.Red
|
||||||
ProfileNameField(fullName)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.full_name_optional__prompt),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
|
)
|
||||||
|
ProfileNameField(fullName, "", ::isValidDisplayName)
|
||||||
|
}
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Row {
|
Row {
|
||||||
SimpleButton(
|
if (chatModel.users.isEmpty()) {
|
||||||
text = stringResource(R.string.about_simplex),
|
SimpleButtonDecorated(
|
||||||
icon = Icons.Outlined.ArrowBackIosNew
|
text = stringResource(R.string.about_simplex),
|
||||||
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
icon = painterResource(R.drawable.ic_arrow_back_ios_new),
|
||||||
|
textDecoration = TextDecoration.None,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
) { chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo }
|
||||||
|
}
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
|
|
||||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
val createModifier: Modifier
|
val createModifier: Modifier
|
||||||
val createColor: Color
|
val createColor: Color
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value) }.padding(8.dp)
|
createModifier = Modifier.clickable { createProfile(chatModel, displayName.value, fullName.value, close) }.padding(8.dp)
|
||||||
createColor = MaterialTheme.colors.primary
|
createColor = MaterialTheme.colors.primary
|
||||||
} else {
|
} else {
|
||||||
createModifier = Modifier.padding(8.dp)
|
createModifier = Modifier.padding(8.dp)
|
||||||
createColor = HighOrLowlight
|
createColor = MaterialTheme.colors.secondary
|
||||||
}
|
}
|
||||||
Surface(shape = RoundedCornerShape(20.dp)) {
|
Surface(shape = RoundedCornerShape(20.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
|
||||||
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor)
|
Text(stringResource(R.string.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
|
||||||
Icon(Icons.Outlined.ArrowForwardIos, stringResource(R.string.create_profile_button), tint = createColor)
|
Icon(painterResource(R.drawable.ic_arrow_forward_ios), stringResource(R.string.create_profile_button), tint = createColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,37 +119,71 @@ fun CreateProfilePanel(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String) {
|
fun createProfile(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) {
|
||||||
withApi {
|
withApi {
|
||||||
val user = chatModel.controller.apiCreateActiveUser(
|
val user = chatModel.controller.apiCreateActiveUser(
|
||||||
Profile(displayName, fullName, null)
|
Profile(displayName, fullName, null)
|
||||||
)
|
) ?: return@withApi
|
||||||
chatModel.controller.startChat(user)
|
chatModel.currentUser.value = user
|
||||||
chatModel.controller.showBackgroundServiceNoticeIfNeeded()
|
if (chatModel.users.isEmpty()) {
|
||||||
SimplexService.start(chatModel.controller.appContext)
|
chatModel.controller.startChat(user)
|
||||||
chatModel.onboardingStage.value = OnboardingStage.OnboardingComplete
|
chatModel.onboardingStage.value = OnboardingStage.Step3_SetNotificationsMode
|
||||||
|
SimplexApp.context.chatModel.controller.ntfManager.createNtfChannelsMaybeShowAlert()
|
||||||
|
} else {
|
||||||
|
val users = chatModel.controller.listUsers()
|
||||||
|
chatModel.users.clear()
|
||||||
|
chatModel.users.addAll(users)
|
||||||
|
chatModel.controller.getUserChatData()
|
||||||
|
close()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileNameField(name: MutableState<String>, focusRequester: FocusRequester? = null) {
|
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
|
||||||
|
var valid by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var focused by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val strokeColor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
if (valid) {
|
||||||
|
if (focused) {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.6f)
|
||||||
|
} else {
|
||||||
|
CurrentColors.value.colors.secondary.copy(alpha = 0.3f)
|
||||||
|
}
|
||||||
|
} else Color.Red
|
||||||
|
}
|
||||||
|
}
|
||||||
val modifier = Modifier
|
val modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.secondary)
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
.height(40.dp)
|
|
||||||
.clip(RoundedCornerShape(5.dp))
|
|
||||||
.padding(8.dp)
|
|
||||||
.navigationBarsWithImePadding()
|
.navigationBarsWithImePadding()
|
||||||
BasicTextField(
|
.onFocusChanged { focused = it.isFocused }
|
||||||
value = name.value,
|
Box(
|
||||||
onValueChange = { name.value = it },
|
Modifier
|
||||||
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
.fillMaxWidth()
|
||||||
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
|
.height(52.dp)
|
||||||
keyboardOptions = KeyboardOptions(
|
.border(border = BorderStroke(1.dp, strokeColor), shape = RoundedCornerShape(50)),
|
||||||
capitalization = KeyboardCapitalization.None,
|
contentAlignment = Alignment.Center
|
||||||
autoCorrect = false
|
) {
|
||||||
),
|
BasicTextField(
|
||||||
singleLine = true,
|
value = name.value,
|
||||||
cursorBrush = SolidColor(HighOrLowlight)
|
onValueChange = { name.value = it },
|
||||||
)
|
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
|
||||||
}
|
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
|
autoCorrect = false
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colors.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { name.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
Log.d(TAG, "CallManager.reportNewIncomingCall")
|
||||||
with (chatModel) {
|
with (chatModel) {
|
||||||
callInvitations[invitation.contact.id] = invitation
|
callInvitations[invitation.contact.id] = invitation
|
||||||
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
if (invitation.user.showNotifications) {
|
||||||
activeCallInvitation.value = invitation
|
if (Clock.System.now() - invitation.callTs <= 3.minutes) {
|
||||||
controller.ntfManager.notifyCallInvitation(invitation)
|
activeCallInvitation.value = invitation
|
||||||
} else {
|
controller.ntfManager.notifyCallInvitation(invitation)
|
||||||
val contact = invitation.contact
|
} else {
|
||||||
controller.ntfManager.notifyMessageReceived(chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
val contact = invitation.contact
|
||||||
|
controller.ntfManager.displayNotification(user = invitation.user, chatId = contact.id, displayName = contact.displayName, msgText = invitation.callTypeText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,4 +106,4 @@ class CallManager(val chatModel: ChatModel) {
|
|||||||
chatModel.controller.ntfManager.cancelCallNotification()
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ package chat.simplex.app.views.call
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.*
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.pm.ActivityInfo
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.*
|
||||||
import android.media.AudioManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.webkit.*
|
import android.webkit.*
|
||||||
@@ -15,16 +16,15 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -37,13 +37,12 @@ import androidx.webkit.WebViewClientCompat
|
|||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.app.views.helpers.withApi
|
import chat.simplex.app.views.helpers.withApi
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
@@ -54,6 +53,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||||
})
|
})
|
||||||
|
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE.name }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// Start service when call happening since it's not already started.
|
// Start service when call happening since it's not already started.
|
||||||
@@ -61,17 +61,48 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
if (!ntfModeService) SimplexService.start(SimplexApp.context)
|
||||||
}
|
}
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
// Stop it when call ended
|
var btDeviceCount = 0
|
||||||
if (!ntfModeService) SimplexService.stop(SimplexApp.context)
|
val audioCallback = object: AudioDeviceCallback() {
|
||||||
// Clear selected communication device to default value after we changed it in call
|
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
|
||||||
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
super.onAudioDevicesAdded(addedDevices)
|
||||||
am.clearCommunicationDevice()
|
val addedCount = addedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
|
btDeviceCount += addedCount
|
||||||
|
audioViaBluetooth.value = btDeviceCount > 0
|
||||||
|
if (addedCount > 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||||
|
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||||
|
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||||
|
Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
|
||||||
|
super.onAudioDevicesRemoved(removedDevices)
|
||||||
|
val removedCount = removedDevices.count { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
|
btDeviceCount -= removedCount
|
||||||
|
audioViaBluetooth.value = btDeviceCount > 0
|
||||||
|
if (btDeviceCount == 0 && chatModel.activeCall.value?.callState == CallState.Connected) {
|
||||||
|
// Setting params in Connected state makes sure that Bluetooth will NOT be broken on Android < 12
|
||||||
|
setCallSound(chatModel.activeCall.value?.soundSpeaker ?: return, audioViaBluetooth)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
am.registerAudioDeviceCallback(audioCallback, null)
|
||||||
|
val pm = (SimplexApp.context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||||
|
val proximityLock = if (pm.isWakeLockLevelSupported(PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
|
||||||
|
pm.newWakeLock(PROXIMITY_SCREEN_OFF_WAKE_LOCK, SimplexApp.context.packageName + ":proximityLock")
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
proximityLock?.acquire()
|
||||||
|
onDispose {
|
||||||
|
// Stop it when call ended
|
||||||
|
if (!ntfModeService) SimplexService.safeStopService(SimplexApp.context)
|
||||||
|
dropAudioManagerOverrides()
|
||||||
|
am.unregisterAudioDeviceCallback(audioCallback)
|
||||||
|
proximityLock?.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val cxt = LocalContext.current
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
WebRTCView(chatModel.callCommand) { apiMsg ->
|
WebRTCView(chatModel.callCommand) { apiMsg ->
|
||||||
@@ -101,6 +132,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||||
if (callStatus == WebRTCCallStatus.Connected) {
|
if (callStatus == WebRTCCallStatus.Connected) {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||||
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
@@ -109,8 +141,7 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
is WCallResponse.Connected -> {
|
is WCallResponse.Connected -> {
|
||||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(2000L)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
setCallSound(cxt, call)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WCallResponse.Ended -> {
|
is WCallResponse.Ended -> {
|
||||||
@@ -144,15 +175,18 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val call = chatModel.activeCall.value
|
val call = chatModel.activeCall.value
|
||||||
if (call != null) ActiveCallOverlay(call, chatModel)
|
if (call != null) ActiveCallOverlay(call, chatModel, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||||
|
val prevVolumeControlStream = activity.volumeControlStream
|
||||||
|
activity.volumeControlStream = AudioManager.STREAM_VOICE_CALL
|
||||||
// Lock orientation to portrait in order to have good experience with calls
|
// Lock orientation to portrait in order to have good experience with calls
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
onDispose {
|
onDispose {
|
||||||
|
activity.volumeControlStream = prevVolumeControlStream
|
||||||
// Unlock orientation
|
// Unlock orientation
|
||||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||||
}
|
}
|
||||||
@@ -160,10 +194,10 @@ fun ActiveCallView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
var cxt = LocalContext.current
|
|
||||||
ActiveCallOverlayLayout(
|
ActiveCallOverlayLayout(
|
||||||
call = call,
|
call = call,
|
||||||
|
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||||
@@ -172,45 +206,62 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel) {
|
|||||||
if (call != null) {
|
if (call != null) {
|
||||||
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
call = call.copy(soundSpeaker = !call.soundSpeaker)
|
||||||
chatModel.activeCall.value = call
|
chatModel.activeCall.value = call
|
||||||
setCallSound(cxt, call)
|
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setCallSound(cxt: Context, call: Call) {
|
private fun setCallSound(speaker: Boolean, audioViaBluetooth: MutableState<Boolean>) {
|
||||||
Log.d(TAG, "setCallSound: set audio mode")
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
val am = cxt.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
Log.d(TAG, "setCallSound: set audio mode, speaker enabled: $speaker")
|
||||||
if (call.soundSpeaker) {
|
am.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||||
am.mode = AudioManager.MODE_NORMAL
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
am.isSpeakerphoneOn = true
|
val btDevice = am.availableCommunicationDevices.lastOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO }
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val preferredSecondaryDevice = if (speaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }?.let {
|
if (btDevice != null) {
|
||||||
|
am.setCommunicationDevice(btDevice)
|
||||||
|
} else if (am.communicationDevice?.type != preferredSecondaryDevice) {
|
||||||
|
am.availableCommunicationDevices.firstOrNull { it.type == preferredSecondaryDevice }?.let {
|
||||||
am.setCommunicationDevice(it)
|
am.setCommunicationDevice(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
am.mode = AudioManager.MODE_IN_CALL
|
if (audioViaBluetooth.value) {
|
||||||
am.isSpeakerphoneOn = false
|
am.isSpeakerphoneOn = false
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
am.startBluetoothSco()
|
||||||
am.availableCommunicationDevices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }?.let {
|
} else {
|
||||||
am.setCommunicationDevice(it)
|
am.stopBluetoothSco()
|
||||||
}
|
am.isSpeakerphoneOn = speaker
|
||||||
}
|
}
|
||||||
|
am.isBluetoothScoOn = am.isBluetoothScoAvailableOffCall && audioViaBluetooth.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dropAudioManagerOverrides() {
|
||||||
|
val am = SimplexApp.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||||
|
am.mode = AudioManager.MODE_NORMAL
|
||||||
|
// Clear selected communication device to default value after we changed it in call
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
am.clearCommunicationDevice()
|
||||||
|
} else {
|
||||||
|
am.isSpeakerphoneOn = false
|
||||||
|
am.stopBluetoothSco()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ActiveCallOverlayLayout(
|
private fun ActiveCallOverlayLayout(
|
||||||
call: Call,
|
call: Call,
|
||||||
|
speakerCanBeEnabled: Boolean,
|
||||||
dismiss: () -> Unit,
|
dismiss: () -> Unit,
|
||||||
toggleAudio: () -> Unit,
|
toggleAudio: () -> Unit,
|
||||||
toggleVideo: () -> Unit,
|
toggleVideo: () -> Unit,
|
||||||
toggleSound: () -> Unit,
|
toggleSound: () -> Unit,
|
||||||
flipCamera: () -> Unit
|
flipCamera: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(DEFAULT_PADDING)) {
|
||||||
when (call.peerMedia ?: call.localMedia) {
|
when (call.peerMedia ?: call.localMedia) {
|
||||||
CallMediaType.Video -> {
|
CallMediaType.Video -> {
|
||||||
CallInfoView(call, alignment = Alignment.Start)
|
CallInfoView(call, alignment = Alignment.Start)
|
||||||
@@ -219,14 +270,14 @@ private fun ActiveCallOverlayLayout(
|
|||||||
ToggleAudioButton(call, toggleAudio)
|
ToggleAudioButton(call, toggleAudio)
|
||||||
Spacer(Modifier.size(40.dp))
|
Spacer(Modifier.size(40.dp))
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
if (call.videoEnabled) {
|
if (call.videoEnabled) {
|
||||||
ControlButton(call, Icons.Filled.FlipCameraAndroid, R.string.icon_descr_flip_camera, flipCamera)
|
ControlButton(call, painterResource(R.drawable.ic_flip_camera_android_filled), R.string.icon_descr_flip_camera, flipCamera)
|
||||||
ControlButton(call, Icons.Filled.Videocam, R.string.icon_descr_video_off, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_filled), R.string.icon_descr_video_off, toggleVideo)
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
ControlButton(call, Icons.Outlined.VideocamOff, R.string.icon_descr_video_on, toggleVideo)
|
ControlButton(call, painterResource(R.drawable.ic_videocam_off), R.string.icon_descr_video_on, toggleVideo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,10 +292,10 @@ private fun ActiveCallOverlayLayout(
|
|||||||
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
CallInfoView(call, alignment = Alignment.CenterHorizontally)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Box(Modifier.fillMaxWidth().padding(bottom = 48.dp), contentAlignment = Alignment.CenterStart) {
|
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
IconButton(onClick = dismiss) {
|
IconButton(onClick = dismiss) {
|
||||||
Icon(Icons.Filled.CallEnd, stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
Icon(painterResource(R.drawable.ic_call_end_filled), stringResource(R.string.icon_descr_hang_up), tint = Color.Red, modifier = Modifier.size(64.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(start = 32.dp)) {
|
Box(Modifier.padding(start = 32.dp)) {
|
||||||
@@ -252,7 +303,7 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||||
Box(Modifier.padding(end = 32.dp)) {
|
Box(Modifier.padding(end = 32.dp)) {
|
||||||
ToggleSoundButton(call, toggleSound)
|
ToggleSoundButton(call, speakerCanBeEnabled, toggleSound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -262,10 +313,10 @@ private fun ActiveCallOverlayLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: Int, action: () -> Unit) {
|
private fun ControlButton(call: Call, icon: Painter, @StringRes iconText: Int, action: () -> Unit, enabled: Boolean = true) {
|
||||||
if (call.hasMedia) {
|
if (call.hasMedia) {
|
||||||
IconButton(onClick = action) {
|
IconButton(onClick = action, enabled = enabled) {
|
||||||
Icon(icon, stringResource(iconText), tint = Color(0xFFFFFFD8), modifier = Modifier.size(40.dp))
|
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(40.dp))
|
Spacer(Modifier.size(40.dp))
|
||||||
@@ -275,18 +326,18 @@ private fun ControlButton(call: Call, icon: ImageVector, @StringRes iconText: In
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
private fun ToggleAudioButton(call: Call, toggleAudio: () -> Unit) {
|
||||||
if (call.audioEnabled) {
|
if (call.audioEnabled) {
|
||||||
ControlButton(call, Icons.Outlined.Mic, R.string.icon_descr_audio_off, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic), R.string.icon_descr_audio_off, toggleAudio)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, Icons.Outlined.MicOff, R.string.icon_descr_audio_on, toggleAudio)
|
ControlButton(call, painterResource(R.drawable.ic_mic_off), R.string.icon_descr_audio_on, toggleAudio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ToggleSoundButton(call: Call, toggleSound: () -> Unit) {
|
private fun ToggleSoundButton(call: Call, enabled: Boolean, toggleSound: () -> Unit) {
|
||||||
if (call.soundSpeaker) {
|
if (call.soundSpeaker) {
|
||||||
ControlButton(call, Icons.Outlined.VolumeUp, R.string.icon_descr_speaker_off, toggleSound)
|
ControlButton(call, painterResource(R.drawable.ic_volume_up), R.string.icon_descr_speaker_off, toggleSound, enabled)
|
||||||
} else {
|
} else {
|
||||||
ControlButton(call, Icons.Outlined.VolumeDown, R.string.icon_descr_speaker_on, toggleSound)
|
ControlButton(call, painterResource(R.drawable.ic_volume_down), R.string.icon_descr_speaker_on, toggleSound, enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,10 +349,10 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
InfoText(call.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
InfoText(call.callState.text)
|
InfoText(call.callState.text)
|
||||||
|
|
||||||
val connInfo =
|
val connInfo = call.connectionInfo
|
||||||
if (call.connectionInfo == null) ""
|
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||||
else " (${call.connectionInfo.text})"
|
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||||
InfoText(call.encryptionStatus + connInfo)
|
InfoText(call.encryptionStatus + connInfoText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +367,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
// horizontalAlignment = Alignment.CenterHorizontally,
|
// horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
// verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
// modifier = Modifier
|
// modifier = Modifier
|
||||||
// .background(MaterialTheme.colors.background)
|
// .themedBackground()
|
||||||
// .fillMaxSize()
|
// .fillMaxSize()
|
||||||
// ) {
|
// ) {
|
||||||
// WebRTCView(callCommand) { apiMsg ->
|
// WebRTCView(callCommand) { apiMsg ->
|
||||||
@@ -357,6 +408,7 @@ fun CallInfoView(call: Call, alignment: Alignment.Horizontal) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||||
val permissionsState = rememberMultiplePermissionsState(
|
val permissionsState = rememberMultiplePermissionsState(
|
||||||
permissions = listOf(
|
permissions = listOf(
|
||||||
@@ -435,7 +487,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
|||||||
Log.d(TAG, "WebRTCView: webview ready")
|
Log.d(TAG, "WebRTCView: webview ready")
|
||||||
// for debugging
|
// for debugging
|
||||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||||
withApi {
|
scope.launch {
|
||||||
delay(2000L)
|
delay(2000L)
|
||||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||||
webView.value = wv
|
webView.value = wv
|
||||||
@@ -480,8 +532,12 @@ fun PreviewActiveCallOverlayVideo() {
|
|||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Video,
|
localMedia = CallMediaType.Video,
|
||||||
peerMedia = CallMediaType.Video,
|
peerMedia = CallMediaType.Video,
|
||||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
connectionInfo = ConnectionInfo(
|
||||||
|
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null),
|
||||||
|
RTCIceCandidate(RTCIceCandidateType.Host, "tcp", null)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
|
speakerCanBeEnabled = true,
|
||||||
dismiss = {},
|
dismiss = {},
|
||||||
toggleAudio = {},
|
toggleAudio = {},
|
||||||
toggleVideo = {},
|
toggleVideo = {},
|
||||||
@@ -501,8 +557,12 @@ fun PreviewActiveCallOverlayAudio() {
|
|||||||
callState = CallState.Negotiated,
|
callState = CallState.Negotiated,
|
||||||
localMedia = CallMediaType.Audio,
|
localMedia = CallMediaType.Audio,
|
||||||
peerMedia = CallMediaType.Audio,
|
peerMedia = CallMediaType.Audio,
|
||||||
connectionInfo = ConnectionInfo(RTCIceCandidate(RTCIceCandidateType.Host), RTCIceCandidate(RTCIceCandidateType.Host))
|
connectionInfo = ConnectionInfo(
|
||||||
|
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null),
|
||||||
|
RTCIceCandidate(RTCIceCandidateType.Host, "udp", null)
|
||||||
|
)
|
||||||
),
|
),
|
||||||
|
speakerCanBeEnabled = true,
|
||||||
dismiss = {},
|
dismiss = {},
|
||||||
toggleAudio = {},
|
toggleAudio = {},
|
||||||
toggleVideo = {},
|
toggleVideo = {},
|
||||||
|
|||||||
+51
-30
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.KeyguardManager
|
import android.app.KeyguardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -11,19 +12,19 @@ import android.view.WindowManager
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -35,7 +36,6 @@ import chat.simplex.app.model.*
|
|||||||
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
import chat.simplex.app.model.NtfManager.Companion.OpenChatAction
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.app.views.onboarding.SimpleXLogo
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
class IncomingCallActivity: ComponentActivity() {
|
class IncomingCallActivity: ComponentActivity() {
|
||||||
@@ -43,8 +43,7 @@ class IncomingCallActivity: ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
val activity = this
|
setContent { IncomingCallActivityView(vm.chatModel) }
|
||||||
setContent { IncomingCallActivityView(vm.chatModel, activity) }
|
|
||||||
unlockForIncomingCall()
|
unlockForIncomingCall()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,11 +82,12 @@ fun getKeyguardManager(context: Context): KeyguardManager =
|
|||||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
fun IncomingCallActivityView(m: ChatModel) {
|
||||||
val switchingCall = m.switchingCall.value
|
val switchingCall = m.switchingCall.value
|
||||||
val invitation = m.activeCallInvitation.value
|
val invitation = m.activeCallInvitation.value
|
||||||
val call = m.activeCall.value
|
val call = m.activeCall.value
|
||||||
val showCallView = m.showCallView.value
|
val showCallView = m.showCallView.value
|
||||||
|
val activity = LocalContext.current as Activity
|
||||||
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
LaunchedEffect(invitation, call, switchingCall, showCallView) {
|
||||||
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
if (!switchingCall && invitation == null && (!showCallView || call == null)) {
|
||||||
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
Log.d(TAG, "IncomingCallActivityView: finishing activity")
|
||||||
@@ -97,7 +97,7 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
.fillMaxSize()) {
|
.fillMaxSize()) {
|
||||||
if (showCallView) {
|
if (showCallView) {
|
||||||
Box {
|
Box {
|
||||||
@@ -105,36 +105,43 @@ fun IncomingCallActivityView(m: ChatModel, activity: IncomingCallActivity) {
|
|||||||
if (invitation != null) IncomingCallAlertView(invitation, m)
|
if (invitation != null) IncomingCallAlertView(invitation, m)
|
||||||
}
|
}
|
||||||
} else if (invitation != null) {
|
} else if (invitation != null) {
|
||||||
IncomingCallLockScreenAlert(invitation, m, activity)
|
IncomingCallLockScreenAlert(invitation, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel, activity: IncomingCallActivity) {
|
fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
val cm = chatModel.callManager
|
val cm = chatModel.callManager
|
||||||
val cxt = LocalContext.current
|
val callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
||||||
val scope = rememberCoroutineScope()
|
val context = LocalContext.current
|
||||||
var callOnLockScreen by remember { mutableStateOf(chatModel.controller.appPrefs.callOnLockScreen.get()) }
|
DisposableEffect(Unit) {
|
||||||
LaunchedEffect(true) { SoundPlayer.shared.start(cxt, scope, sound = true) }
|
onDispose {
|
||||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
// Cancel notification whatever happens next since otherwise sound from notification and from inside the app can co-exist
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
invitation,
|
invitation,
|
||||||
callOnLockScreen,
|
callOnLockScreen,
|
||||||
|
chatModel,
|
||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
ignoreCall = {
|
||||||
|
chatModel.activeCallInvitation.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) },
|
||||||
openApp = {
|
openApp = {
|
||||||
SoundPlayer.shared.stop()
|
val intent = Intent(context, MainActivity::class.java)
|
||||||
var intent = Intent(activity, MainActivity::class.java)
|
|
||||||
.setAction(OpenChatAction)
|
.setAction(OpenChatAction)
|
||||||
|
.putExtra("userId", invitation.user.userId)
|
||||||
.putExtra("chatId", invitation.contact.id)
|
.putExtra("chatId", invitation.contact.id)
|
||||||
activity.startActivity(intent)
|
context.startActivity(intent)
|
||||||
activity.finish()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
getKeyguardManager(activity).requestDismissKeyguard(activity, null)
|
getKeyguardManager(context).requestDismissKeyguard((context as Activity), null)
|
||||||
}
|
}
|
||||||
|
(context as Activity).finish()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -143,6 +150,7 @@ fun IncomingCallLockScreenAlert(invitation: RcvCallInvitation, chatModel: ChatMo
|
|||||||
fun IncomingCallLockScreenAlertLayout(
|
fun IncomingCallLockScreenAlertLayout(
|
||||||
invitation: RcvCallInvitation,
|
invitation: RcvCallInvitation,
|
||||||
callOnLockScreen: CallOnLockScreen?,
|
callOnLockScreen: CallOnLockScreen?,
|
||||||
|
chatModel: ChatModel,
|
||||||
rejectCall: () -> Unit,
|
rejectCall: () -> Unit,
|
||||||
ignoreCall: () -> Unit,
|
ignoreCall: () -> Unit,
|
||||||
acceptCall: () -> Unit,
|
acceptCall: () -> Unit,
|
||||||
@@ -154,31 +162,42 @@ fun IncomingCallLockScreenAlertLayout(
|
|||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
IncomingCallInfo(invitation)
|
IncomingCallInfo(invitation, chatModel)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
if (callOnLockScreen == CallOnLockScreen.ACCEPT) {
|
||||||
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
ProfileImage(size = 192.dp, image = invitation.contact.profile.image)
|
||||||
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
Text(invitation.contact.chatViewName, style = MaterialTheme.typography.h2)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
Row {
|
Row {
|
||||||
LockScreenCallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
LockScreenCallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
LockScreenCallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
Spacer(Modifier.size(48.dp))
|
Spacer(Modifier.size(48.dp))
|
||||||
LockScreenCallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
LockScreenCallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
} else if (callOnLockScreen == CallOnLockScreen.SHOW) {
|
||||||
SimpleXLogo()
|
SimpleXLogo()
|
||||||
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
Text(stringResource(R.string.open_simplex_chat_to_accept_call), textAlign = TextAlign.Center, lineHeight = 22.sp)
|
||||||
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
Text(stringResource(R.string.allow_accepting_calls_from_lock_screen), textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, lineHeight = 22.sp)
|
||||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||||
SimpleButton(text = stringResource(R.string.open_verb), icon = Icons.Filled.Check, click = openApp)
|
SimpleButton(text = stringResource(R.string.open_verb), icon = painterResource(R.drawable.ic_check_filled), click = openApp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
private fun SimpleXLogo() {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(if (isInDarkTheme()) R.drawable.logo_light else R.drawable.logo),
|
||||||
|
contentDescription = stringResource(R.string.image_descr_simplex_logo),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(vertical = DEFAULT_PADDING)
|
||||||
|
.fillMaxWidth(0.80f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockScreenCallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
color = Color.Transparent
|
color = Color.Transparent
|
||||||
@@ -192,8 +211,8 @@ private fun LockScreenCallButton(text: String, icon: ImageVector, color: Color,
|
|||||||
IconButton(action) {
|
IconButton(action) {
|
||||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.75f))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,16 +226,18 @@ fun PreviewIncomingCallLockScreenAlert() {
|
|||||||
SimpleXTheme(true) {
|
SimpleXTheme(true) {
|
||||||
Surface(
|
Surface(
|
||||||
Modifier
|
Modifier
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
.fillMaxSize()) {
|
.fillMaxSize()) {
|
||||||
IncomingCallLockScreenAlertLayout(
|
IncomingCallLockScreenAlertLayout(
|
||||||
invitation = RcvCallInvitation(
|
invitation = RcvCallInvitation(
|
||||||
|
user = User.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
sharedKey = null,
|
sharedKey = null,
|
||||||
callTs = Clock.System.now()
|
callTs = Clock.System.now()
|
||||||
),
|
),
|
||||||
callOnLockScreen = null,
|
callOnLockScreen = null,
|
||||||
|
chatModel = SimplexApp.context.chatModel,
|
||||||
rejectCall = {},
|
rejectCall = {},
|
||||||
ignoreCall = {},
|
ignoreCall = {},
|
||||||
acceptCall = {},
|
acceptCall = {},
|
||||||
|
|||||||
+29
-18
@@ -4,22 +4,22 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.Contact
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
import chat.simplex.app.views.usersettings.ProfilePreview
|
import chat.simplex.app.views.usersettings.ProfilePreview
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@@ -32,8 +32,12 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
|||||||
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
DisposableEffect(true) { onDispose { SoundPlayer.shared.stop() } }
|
||||||
IncomingCallAlertLayout(
|
IncomingCallAlertLayout(
|
||||||
invitation,
|
invitation,
|
||||||
|
chatModel,
|
||||||
rejectCall = { cm.endCall(invitation = invitation) },
|
rejectCall = { cm.endCall(invitation = invitation) },
|
||||||
ignoreCall = { chatModel.activeCallInvitation.value = null },
|
ignoreCall = {
|
||||||
|
chatModel.activeCallInvitation.value = null
|
||||||
|
chatModel.controller.ntfManager.cancelCallNotification()
|
||||||
|
},
|
||||||
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
acceptCall = { cm.acceptIncomingCall(invitation = invitation) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,40 +45,45 @@ fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallAlertLayout(
|
fun IncomingCallAlertLayout(
|
||||||
invitation: RcvCallInvitation,
|
invitation: RcvCallInvitation,
|
||||||
|
chatModel: ChatModel,
|
||||||
rejectCall: () -> Unit,
|
rejectCall: () -> Unit,
|
||||||
ignoreCall: () -> Unit,
|
ignoreCall: () -> Unit,
|
||||||
acceptCall: () -> Unit
|
acceptCall: () -> Unit
|
||||||
) {
|
) {
|
||||||
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
val color = if (isInDarkTheme()) IncomingCallDark else IncomingCallLight
|
||||||
Column(Modifier.fillMaxWidth().background(color).padding(top = 16.dp, bottom = 16.dp, start = 16.dp, end = 8.dp)) {
|
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
|
||||||
IncomingCallInfo(invitation)
|
IncomingCallInfo(invitation, chatModel)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxWidth().weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
ProfilePreview(profileOf = invitation.contact, size = 64.dp, color = Color.White)
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
CallButton(stringResource(R.string.reject), Icons.Filled.CallEnd, Color.Red, rejectCall)
|
CallButton(stringResource(R.string.reject), painterResource(R.drawable.ic_call_end_filled), Color.Red, rejectCall)
|
||||||
CallButton(stringResource(R.string.ignore), Icons.Filled.Close, MaterialTheme.colors.primary, ignoreCall)
|
CallButton(stringResource(R.string.ignore), painterResource(R.drawable.ic_close), MaterialTheme.colors.primary, ignoreCall)
|
||||||
CallButton(stringResource(R.string.accept), Icons.Filled.Check, SimplexGreen, acceptCall)
|
CallButton(stringResource(R.string.accept), painterResource(R.drawable.ic_check_filled), SimplexGreen, acceptCall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncomingCallInfo(invitation: RcvCallInvitation) {
|
fun IncomingCallInfo(invitation: RcvCallInvitation, chatModel: ChatModel) {
|
||||||
@Composable fun CallIcon(icon: ImageVector, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
@Composable fun CallIcon(icon: Painter, descr: String) = Icon(icon, descr, tint = SimplexGreen)
|
||||||
Row {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (invitation.callType.media == CallMediaType.Video) CallIcon(Icons.Filled.Videocam, stringResource(R.string.icon_descr_video_call))
|
if (chatModel.users.size > 1) {
|
||||||
else CallIcon(Icons.Filled.Phone, stringResource(R.string.icon_descr_audio_call))
|
ProfileImage(size = 32.dp, image = invitation.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
if (invitation.callType.media == CallMediaType.Video) CallIcon(painterResource(R.drawable.ic_videocam_filled), stringResource(R.string.icon_descr_video_call))
|
||||||
|
else CallIcon(painterResource(R.drawable.ic_call_filled), stringResource(R.string.icon_descr_audio_call))
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
Text(invitation.callTypeText)
|
Text(invitation.callTypeText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CallButton(text: String, icon: ImageVector, color: Color, action: () -> Unit) {
|
private fun CallButton(text: String, icon: Painter, color: Color, action: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(10.dp),
|
shape = RoundedCornerShape(10.dp),
|
||||||
color = Color.Transparent
|
color = Color.Transparent
|
||||||
@@ -87,7 +96,7 @@ private fun CallButton(text: String, icon: ImageVector, color: Color, action: ()
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
Icon(icon, text, tint = color, modifier = Modifier.scale(1.2f))
|
||||||
Text(text, style = MaterialTheme.typography.body2, color = HighOrLowlight)
|
Text(text, style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,11 +107,13 @@ fun PreviewIncomingCallAlertLayout() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
IncomingCallAlertLayout(
|
IncomingCallAlertLayout(
|
||||||
invitation = RcvCallInvitation(
|
invitation = RcvCallInvitation(
|
||||||
|
user = User.sampleData,
|
||||||
contact = Contact.sampleData,
|
contact = Contact.sampleData,
|
||||||
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
callType = CallType(media = CallMediaType.Audio, capabilities = CallCapabilities(encryption = false)),
|
||||||
sharedKey = null,
|
sharedKey = null,
|
||||||
callTs = Clock.System.now()
|
callTs = Clock.System.now()
|
||||||
),
|
),
|
||||||
|
chatModel = SimplexApp.context.chatModel,
|
||||||
rejectCall = {},
|
rejectCall = {},
|
||||||
ignoreCall = {},
|
ignoreCall = {},
|
||||||
acceptCall = {}
|
acceptCall = {}
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ class SoundPlayer {
|
|||||||
companion object {
|
companion object {
|
||||||
val shared = SoundPlayer()
|
val shared = SoundPlayer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
package chat.simplex.app.views.call
|
package chat.simplex.app.views.call
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import chat.simplex.app.R
|
import androidx.compose.ui.text.toUpperCase
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.Contact
|
import chat.simplex.app.model.Contact
|
||||||
|
import chat.simplex.app.model.User
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.collections.ArrayList
|
||||||
|
|
||||||
data class Call(
|
data class Call(
|
||||||
val contact: Contact,
|
val contact: Contact,
|
||||||
@@ -61,39 +65,39 @@ enum class CallState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||||
@Serializable class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class WCallCommand {
|
sealed class WCallCommand {
|
||||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||||
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||||
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
|
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||||
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||||
@Serializable @SerialName("camera") class Camera(val camera: VideoCamera): WCallCommand()
|
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||||
@Serializable @SerialName("end") object End: WCallCommand()
|
@Serializable @SerialName("end") object End: WCallCommand()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class WCallResponse {
|
sealed class WCallResponse {
|
||||||
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
@Serializable @SerialName("capabilities") data class Capabilities(val capabilities: CallCapabilities): WCallResponse()
|
||||||
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
|
||||||
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
@Serializable @SerialName("answer") data class Answer(val answer: String, val iceCandidates: String): WCallResponse()
|
||||||
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
|
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||||
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
|
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||||
@Serializable @SerialName("connected") class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||||
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
|
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
@Serializable data class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
|
||||||
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
@Serializable data class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
|
||||||
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
|
@Serializable data class WebRTCExtraInfo(val rtcIceCandidates: String)
|
||||||
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
@Serializable data class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
|
||||||
@Serializable class RcvCallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
@Serializable data class RcvCallInvitation(val user: User, val contact: Contact, val callType: CallType, val sharedKey: String? = null, val callTs: Instant) {
|
||||||
val callTypeText: String get() = generalGetString(when(callType.media) {
|
val callTypeText: String get() = generalGetString(when(callType.media) {
|
||||||
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
CallMediaType.Video -> if (sharedKey == null) R.string.video_call_no_encryption else R.string.encrypted_video_call
|
||||||
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
CallMediaType.Audio -> if (sharedKey == null) R.string.audio_call_no_encryption else R.string.encrypted_audio_call
|
||||||
@@ -103,19 +107,32 @@ sealed class WCallResponse {
|
|||||||
CallMediaType.Audio -> R.string.incoming_audio_call
|
CallMediaType.Audio -> R.string.incoming_audio_call
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@Serializable class CallCapabilities(val encryption: Boolean)
|
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||||
@Serializable class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||||
val text: String @Composable get() = when {
|
val text: String @Composable get() {
|
||||||
localCandidate?.candidateType == RTCIceCandidateType.Host && remoteCandidate?.candidateType == RTCIceCandidateType.Host ->
|
val local = localCandidate?.candidateType
|
||||||
stringResource(R.string.call_connection_peer_to_peer)
|
val remote = remoteCandidate?.candidateType
|
||||||
localCandidate?.candidateType == RTCIceCandidateType.Relay && remoteCandidate?.candidateType == RTCIceCandidateType.Relay ->
|
return when {
|
||||||
stringResource(R.string.call_connection_via_relay)
|
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||||
else ->
|
stringResource(R.string.call_connection_peer_to_peer)
|
||||||
"${localCandidate?.candidateType?.value ?: "unknown"} / ${remoteCandidate?.candidateType?.value ?: "unknown"}"
|
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||||
|
stringResource(R.string.call_connection_via_relay)
|
||||||
|
else ->
|
||||||
|
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val protocolText: String get() {
|
||||||
|
val local = localCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||||
|
val localRelay = localCandidate?.relayProtocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||||
|
val remote = remoteCandidate?.protocol?.uppercase(Locale.ROOT) ?: "unknown"
|
||||||
|
val localText = if (localRelay == local || localCandidate?.relayProtocol == null) local else "$local ($localRelay)"
|
||||||
|
return if (local == remote) localText else "$localText / $remote"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceCandidate
|
||||||
@Serializable class RTCIceCandidate(val candidateType: RTCIceCandidateType?)
|
@Serializable data class RTCIceCandidate(val candidateType: RTCIceCandidateType?, val protocol: String?, val relayProtocol: String?)
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
// https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer
|
||||||
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
@Serializable data class RTCIceServer(val urls: List<String>, val username: String? = null, val credential: String? = null)
|
||||||
|
|
||||||
@@ -150,7 +167,7 @@ enum class VideoCamera {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
class ConnectionState(
|
data class ConnectionState(
|
||||||
val connectionState: String,
|
val connectionState: String,
|
||||||
val iceConnectionState: String,
|
val iceConnectionState: String,
|
||||||
val iceGatheringState: String,
|
val iceGatheringState: String,
|
||||||
@@ -158,20 +175,22 @@ class ConnectionState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// the servers are expected in this format:
|
// the servers are expected in this format:
|
||||||
// stun:stun.simplex.im:443
|
// stun:stun.simplex.im:443?transport=tcp
|
||||||
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443
|
// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp
|
||||||
fun parseRTCIceServer(str: String): RTCIceServer? {
|
fun parseRTCIceServer(str: String): RTCIceServer? {
|
||||||
var s = replaceScheme(str, "stun:")
|
var s = replaceScheme(str, "stun:")
|
||||||
s = replaceScheme(s, "turn:")
|
s = replaceScheme(s, "turn:")
|
||||||
|
s = replaceScheme(s, "turns:")
|
||||||
val u = runCatching { URI(s) }.getOrNull()
|
val u = runCatching { URI(s) }.getOrNull()
|
||||||
if (u != null) {
|
if (u != null) {
|
||||||
val scheme = u.scheme
|
val scheme = u.scheme
|
||||||
val host = u.host
|
val host = u.host
|
||||||
val port = u.port
|
val port = u.port
|
||||||
if (u.path == "" && (scheme == "stun" || scheme == "turn")) {
|
if (u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns")) {
|
||||||
val userInfo = u.userInfo?.split(":")
|
val userInfo = u.userInfo?.split(":")
|
||||||
|
val query = if (u.query == null || u.query == "") "" else "?${u.query}"
|
||||||
return RTCIceServer(
|
return RTCIceServer(
|
||||||
urls = listOf("$scheme:$host:$port"),
|
urls = listOf("$scheme:$host:$port$query"),
|
||||||
username = userInfo?.getOrNull(0),
|
username = userInfo?.getOrNull(0),
|
||||||
credential = userInfo?.getOrNull(1)
|
credential = userInfo?.getOrNull(1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package chat.simplex.app.views.chat
|
|||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import InfoRowEllipsis
|
import InfoRowEllipsis
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
@@ -12,9 +13,6 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -22,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
@@ -35,9 +34,10 @@ import chat.simplex.app.SimplexApp
|
|||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoView(
|
fun ChatInfoView(
|
||||||
@@ -46,33 +46,72 @@ fun ChatInfoView(
|
|||||||
connStats: ConnectionStats?,
|
connStats: ConnectionStats?,
|
||||||
customUserProfile: Profile?,
|
customUserProfile: Profile?,
|
||||||
localAlias: String,
|
localAlias: String,
|
||||||
|
connectionCode: String?,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
if (chat != null) {
|
if (chat != null) {
|
||||||
|
val contactNetworkStatus = remember(chatModel.networkStatuses.toMap()) {
|
||||||
|
mutableStateOf(chatModel.contactNetworkStatus(contact))
|
||||||
|
}
|
||||||
ChatInfoLayout(
|
ChatInfoLayout(
|
||||||
chat,
|
chat,
|
||||||
contact,
|
contact,
|
||||||
connStats,
|
connStats,
|
||||||
|
contactNetworkStatus.value,
|
||||||
customUserProfile,
|
customUserProfile,
|
||||||
localAlias,
|
localAlias,
|
||||||
|
connectionCode,
|
||||||
developerTools,
|
developerTools,
|
||||||
onLocalAliasChanged = {
|
onLocalAliasChanged = {
|
||||||
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
setContactAlias(chat.chatInfo.apiId, it, chatModel)
|
||||||
},
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
val user = chatModel.currentUser.value
|
||||||
|
if (user != null) {
|
||||||
|
ContactPreferencesView(chatModel, user, contact.contactId, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
deleteContact = { deleteContactDialog(chat.chatInfo, chatModel, close) },
|
||||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
switchContactAddress = {
|
switchContactAddress = {
|
||||||
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
showSwitchContactAddressAlert(chatModel, contact.contactId)
|
||||||
|
},
|
||||||
|
verifyClicked = {
|
||||||
|
ModalManager.shared.showModalCloseable { close ->
|
||||||
|
remember { derivedStateOf { (chatModel.getContactChat(contact.contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||||
|
VerifyCodeView(
|
||||||
|
ct.displayName,
|
||||||
|
connectionCode,
|
||||||
|
ct.verified,
|
||||||
|
verify = { code ->
|
||||||
|
chatModel.controller.apiVerifyContact(ct.contactId, code)?.let { r ->
|
||||||
|
val (verified, existingCode) = r
|
||||||
|
chatModel.updateContact(
|
||||||
|
ct.copy(
|
||||||
|
activeConn = ct.activeConn.copy(
|
||||||
|
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_contact_question),
|
title = generalGetString(R.string.delete_contact_question),
|
||||||
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -86,12 +125,13 @@ fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() ->
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.clear_chat_question),
|
title = generalGetString(R.string.clear_chat_question),
|
||||||
text = generalGetString(R.string.clear_chat_warning),
|
text = generalGetString(R.string.clear_chat_warning),
|
||||||
confirmText = generalGetString(R.string.clear_verb),
|
confirmText = generalGetString(R.string.clear_verb),
|
||||||
@@ -104,7 +144,8 @@ fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,19 +154,22 @@ fun ChatInfoLayout(
|
|||||||
chat: Chat,
|
chat: Chat,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
connStats: ConnectionStats?,
|
connStats: ConnectionStats?,
|
||||||
|
contactNetworkStatus: NetworkStatus,
|
||||||
customUserProfile: Profile?,
|
customUserProfile: Profile?,
|
||||||
localAlias: String,
|
localAlias: String,
|
||||||
|
connectionCode: String?,
|
||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
onLocalAliasChanged: (String) -> Unit,
|
onLocalAliasChanged: (String) -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
deleteContact: () -> Unit,
|
deleteContact: () -> Unit,
|
||||||
clearChat: () -> Unit,
|
clearChat: () -> Unit,
|
||||||
switchContactAddress: () -> Unit,
|
switchContactAddress: () -> Unit,
|
||||||
|
verifyClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState())
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -135,57 +179,57 @@ fun ChatInfoLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
LocalAliasEditor(localAlias, updateValue = onLocalAliasChanged)
|
||||||
|
SectionSpacer()
|
||||||
if (customUserProfile != null) {
|
if (customUserProfile != null) {
|
||||||
SectionSpacer()
|
|
||||||
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
SectionView(generalGetString(R.string.incognito).uppercase()) {
|
||||||
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
InfoRow(generalGetString(R.string.incognito_random_profile), customUserProfile.chatViewName)
|
||||||
}
|
}
|
||||||
|
SectionDividerSpaced()
|
||||||
}
|
}
|
||||||
|
|
||||||
SectionSpacer()
|
SectionView {
|
||||||
|
if (connectionCode != null) {
|
||||||
|
VerifyCodeButton(contact.verified, verifyClicked)
|
||||||
|
}
|
||||||
|
ContactPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionDividerSpaced()
|
||||||
|
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
if (developerTools) {
|
SwitchAddressButton(switchContactAddress)
|
||||||
SwitchAddressButton(switchContactAddress)
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
if (connStats != null) {
|
if (connStats != null) {
|
||||||
SectionItemView({
|
SectionItemView({
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.network_status),
|
generalGetString(R.string.network_status),
|
||||||
chat.serverInfo.networkStatus.statusExplanation
|
contactNetworkStatus.statusExplanation
|
||||||
)}) {
|
)}) {
|
||||||
NetworkStatusRow(chat.serverInfo.networkStatus)
|
NetworkStatusRow(contactNetworkStatus)
|
||||||
}
|
}
|
||||||
val rcvServers = connStats.rcvServers
|
val rcvServers = connStats.rcvServers
|
||||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
}
|
}
|
||||||
val sndServers = connStats.sndServers
|
val sndServers = connStats.sndServers
|
||||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced()
|
||||||
SectionView {
|
SectionView {
|
||||||
ClearChatButton(clearChat)
|
ClearChatButton(clearChat)
|
||||||
SectionDivider()
|
|
||||||
DeleteContactButton(deleteContact)
|
DeleteContactButton(deleteContact)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), chat.chatInfo.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), chat.chatInfo.apiId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,13 +240,17 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
Text(
|
Row(Modifier.padding(bottom = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
if (contact.verified) {
|
||||||
color = MaterialTheme.colors.onBackground,
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
maxLines = 1,
|
}
|
||||||
overflow = TextOverflow.Ellipsis,
|
Text(
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
contact.profile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
)
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||||
Text(
|
Text(
|
||||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||||
@@ -235,13 +283,13 @@ fun LocalAliasEditor(
|
|||||||
Text(
|
Text(
|
||||||
generalGetString(R.string.text_field_set_contact_placeholder),
|
generalGetString(R.string.text_field_set_contact_placeholder),
|
||||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
leadingIcon = if (leadingIcon) {
|
leadingIcon = if (leadingIcon) {
|
||||||
{ Icon(Icons.Default.Edit, null, Modifier.padding(start = 7.dp)) }
|
{ Icon(painterResource(R.drawable.ic_edit_filled), null, Modifier.padding(start = 7.dp)) }
|
||||||
} else null,
|
} else null,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
focus = focus,
|
focus = focus,
|
||||||
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
textStyle = TextStyle.Default.copy(textAlign = if (value.isEmpty() || !center) TextAlign.Start else TextAlign.Center),
|
||||||
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
keyboardActions = KeyboardActions(onDone = { updateValue(value) })
|
||||||
@@ -264,7 +312,7 @@ fun LocalAliasEditor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
@@ -276,7 +324,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.network_status))
|
Text(stringResource(R.string.network_status))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Info,
|
painterResource(R.drawable.ic_info),
|
||||||
stringResource(R.string.network_status),
|
stringResource(R.string.network_status),
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
@@ -288,7 +336,7 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
networkStatus.statusString,
|
networkStatus.statusString,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
ServerImage(networkStatus)
|
ServerImage(networkStatus)
|
||||||
}
|
}
|
||||||
@@ -296,16 +344,16 @@ fun NetworkStatusRow(networkStatus: Chat.NetworkStatus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(networkStatus: Chat.NetworkStatus) {
|
private fun ServerImage(networkStatus: NetworkStatus) {
|
||||||
Box(Modifier.size(18.dp)) {
|
Box(Modifier.size(18.dp)) {
|
||||||
when (networkStatus) {
|
when (networkStatus) {
|
||||||
is Chat.NetworkStatus.Connected ->
|
is NetworkStatus.Connected ->
|
||||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
Icon(painterResource(R.drawable.ic_circle_filled), stringResource(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant)
|
||||||
is Chat.NetworkStatus.Disconnected ->
|
is NetworkStatus.Disconnected ->
|
||||||
Icon(Icons.Filled.Pending, stringResource(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_pending_filled), stringResource(R.string.icon_descr_server_status_disconnected), tint = MaterialTheme.colors.secondary)
|
||||||
is Chat.NetworkStatus.Error ->
|
is NetworkStatus.Error ->
|
||||||
Icon(Icons.Filled.Error, stringResource(R.string.icon_descr_server_status_error), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_error_filled), stringResource(R.string.icon_descr_server_status_error), tint = MaterialTheme.colors.secondary)
|
||||||
else -> Icon(Icons.Outlined.Circle, stringResource(R.string.icon_descr_server_status_pending), tint = HighOrLowlight)
|
else -> Icon(painterResource(R.drawable.ic_circle), stringResource(R.string.icon_descr_server_status_pending), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,10 +375,29 @@ fun SwitchAddressButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerifyCodeButton(contactVerified: Boolean, onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
if (contactVerified) painterResource(R.drawable.ic_verified_user) else painterResource(R.drawable.ic_shield),
|
||||||
|
stringResource(if (contactVerified) R.string.view_security_code else R.string.verify_security_code),
|
||||||
|
click = onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPreferencesButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
|
stringResource(R.string.contact_preferences),
|
||||||
|
click = onClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ClearChatButton(onClick: () -> Unit) {
|
fun ClearChatButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Restore,
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
stringResource(R.string.clear_chat_button),
|
stringResource(R.string.clear_chat_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = WarningOrange,
|
textColor = WarningOrange,
|
||||||
@@ -339,9 +406,9 @@ fun ClearChatButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteContactButton(onClick: () -> Unit) {
|
private fun DeleteContactButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.button_delete_contact),
|
stringResource(R.string.button_delete_contact),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -356,13 +423,14 @@ private fun setContactAlias(contactApiId: Long, localAlias: String, chatModel: C
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
private fun showSwitchContactAddressAlert(m: ChatModel, contactId: Long) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.switch_receiving_address_question),
|
title = generalGetString(R.string.switch_receiving_address_question),
|
||||||
text = generalGetString(R.string.switch_receiving_address_desc),
|
text = generalGetString(R.string.switch_receiving_address_desc),
|
||||||
confirmText = generalGetString(R.string.switch_verb),
|
confirmText = generalGetString(R.string.switch_verb),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
switchContactAddress(m, contactId)
|
switchContactAddress(m, contactId)
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,18 +445,21 @@ fun PreviewChatInfoLayout() {
|
|||||||
ChatInfoLayout(
|
ChatInfoLayout(
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
chatItems = arrayListOf(),
|
chatItems = arrayListOf()
|
||||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
|
||||||
),
|
),
|
||||||
Contact.sampleData,
|
Contact.sampleData,
|
||||||
localAlias = "",
|
localAlias = "",
|
||||||
|
connectionCode = "123",
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
connStats = null,
|
connStats = null,
|
||||||
|
contactNetworkStatus = NetworkStatus.Connected(),
|
||||||
onLocalAliasChanged = {},
|
onLocalAliasChanged = {},
|
||||||
customUserProfile = null,
|
customUserProfile = null,
|
||||||
|
openPreferences = {},
|
||||||
deleteContact = {},
|
deleteContact = {},
|
||||||
clearChat = {},
|
clearChat = {},
|
||||||
switchContactAddress = {},
|
switchContactAddress = {},
|
||||||
|
verifyClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.util.Log
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.gestures.*
|
import androidx.compose.foundation.gestures.*
|
||||||
@@ -13,10 +12,6 @@ import androidx.compose.foundation.lazy.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.mapSaver
|
import androidx.compose.runtime.saveable.mapSaver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -25,6 +20,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -52,18 +48,23 @@ import java.io.File
|
|||||||
import kotlin.math.sign
|
import kotlin.math.sign
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatView(chatModel: ChatModel) {
|
fun ChatView(chatId: String, chatModel: ChatModel, onComposed: () -> Unit) {
|
||||||
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }) }
|
val activeChat = remember { mutableStateOf(chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }) }
|
||||||
val searchText = rememberSaveable { mutableStateOf("") }
|
val searchText = rememberSaveable { mutableStateOf("") }
|
||||||
val user = chatModel.currentUser.value
|
val user = chatModel.currentUser.value
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
val composeState = rememberSaveable(saver = ComposeState.saver()) {
|
val composeState = rememberSaveable(saver = ComposeState.saver()) {
|
||||||
mutableStateOf(ComposeState(useLinkPreviews = useLinkPreviews))
|
mutableStateOf(
|
||||||
|
if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) {
|
||||||
|
chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
|
} else {
|
||||||
|
ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
val attachmentOption = rememberSaveable { mutableStateOf<AttachmentOption?>(null) }
|
||||||
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
// snapshotFlow here is because it reacts much faster on changes in chatModel.chatId.value.
|
||||||
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
// With LaunchedEffect(chatModel.chatId.value) there is a noticeable delay before reconstruction of the view
|
||||||
@@ -80,16 +81,23 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
launch {
|
launch {
|
||||||
// .toList() is important for making observation working
|
snapshotFlow {
|
||||||
snapshotFlow { chatModel.chats.toList() }
|
/**
|
||||||
.distinctUntilChanged()
|
* It's possible that in some cases concurrent modification can happen on [ChatModel.chats] list.
|
||||||
.collect { chats ->
|
* In this case only error log will be printed here (no crash).
|
||||||
chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }.let {
|
* TODO: Re-write [ChatModel.chats] logic to a new list assignment instead of changing content of mutableList to prevent that
|
||||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
* */
|
||||||
if (it?.chatInfo != activeChat.value?.chatInfo) {
|
try {
|
||||||
activeChat.value = it
|
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
|
||||||
}}
|
} catch (e: ConcurrentModificationException) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||||
|
.filter { it?.chatInfo != activeChat.value?.chatInfo && it != null }
|
||||||
|
.collect { activeChat.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -97,17 +105,15 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
} else {
|
} else {
|
||||||
val chat = activeChat.value!!
|
val chat = activeChat.value!!
|
||||||
BackHandler { chatModel.chatId.value = null }
|
|
||||||
// We need to have real unreadCount value for displaying it inside top right button
|
// We need to have real unreadCount value for displaying it inside top right button
|
||||||
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
// Having activeChat reloaded on every change in it is inefficient (UI lags)
|
||||||
val unreadCount = remember {
|
val unreadCount = remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }?.chatStats?.unreadCount ?: 0
|
chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatId }?.chatStats?.unreadCount ?: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user,
|
|
||||||
chat,
|
chat,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
composeState,
|
composeState,
|
||||||
@@ -120,29 +126,38 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
attachmentOption,
|
attachmentOption,
|
||||||
scope,
|
|
||||||
attachmentBottomSheetState,
|
attachmentBottomSheetState,
|
||||||
chatModel.chatItems,
|
chatModel.chatItems,
|
||||||
searchText,
|
searchText,
|
||||||
useLinkPreviews = useLinkPreviews,
|
useLinkPreviews = useLinkPreviews,
|
||||||
|
linkMode = chatModel.simplexLinkMode.value,
|
||||||
chatModelIncognito = chatModel.incognito.value,
|
chatModelIncognito = chatModel.incognito.value,
|
||||||
back = {
|
back = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
|
AudioPlayer.stop()
|
||||||
chatModel.chatId.value = null
|
chatModel.chatId.value = null
|
||||||
},
|
},
|
||||||
info = {
|
info = {
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
withApi {
|
withApi {
|
||||||
val cInfo = chat.chatInfo
|
if (chat.chatInfo is ChatInfo.Direct) {
|
||||||
if (cInfo is ChatInfo.Direct) {
|
val contactInfo = chatModel.controller.apiContactInfo(chat.chatInfo.apiId)
|
||||||
val contactInfo = chatModel.controller.apiContactInfo(cInfo.apiId)
|
val (_, code) = chatModel.controller.apiGetContactCode(chat.chatInfo.apiId)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
ChatInfoView(chatModel, cInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, close)
|
remember { derivedStateOf { (chatModel.getContactChat(chat.chatInfo.apiId)?.chatInfo as? ChatInfo.Direct)?.contact } }.value?.let { ct ->
|
||||||
|
ChatInfoView(chatModel, ct, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (cInfo is ChatInfo.Group) {
|
} else if (chat.chatInfo is ChatInfo.Group) {
|
||||||
setGroupMembers(cInfo.groupInfo, chatModel)
|
setGroupMembers(chat.chatInfo.groupInfo, chatModel)
|
||||||
|
val link = chatModel.controller.apiGetGroupLink(chat.chatInfo.groupInfo.groupId)
|
||||||
|
var groupLink = link?.first
|
||||||
|
var groupLinkMemberRole = link?.second
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
GroupChatInfoView(chatModel, close)
|
GroupChatInfoView(chatModel, groupLink, groupLinkMemberRole, {
|
||||||
|
groupLink = it.first;
|
||||||
|
groupLinkMemberRole = it.second
|
||||||
|
}, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,8 +166,21 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
withApi {
|
withApi {
|
||||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||||
|
val (_, code) = if (member.memberActive) {
|
||||||
|
try {
|
||||||
|
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
member to null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
member to null
|
||||||
|
}
|
||||||
|
setGroupMembers(groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, close, close)
|
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||||
|
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -168,17 +196,42 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
deleteMessage = { itemId, mode ->
|
deleteMessage = { itemId, mode ->
|
||||||
withApi {
|
withApi {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
val toItem = chatModel.controller.apiDeleteChatItem(
|
val toDeleteItem = chatModel.chatItems.firstOrNull { it.id == itemId }
|
||||||
type = cInfo.chatType,
|
val toModerate = toDeleteItem?.memberToModerate(chat.chatInfo)
|
||||||
id = cInfo.apiId,
|
val groupInfo = toModerate?.first
|
||||||
itemId = itemId,
|
val groupMember = toModerate?.second
|
||||||
mode = mode
|
val deletedChatItem: ChatItem?
|
||||||
)
|
val toChatItem: ChatItem?
|
||||||
if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem)
|
if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||||
|
val r = chatModel.controller.apiDeleteMemberChatItem(
|
||||||
|
groupId = groupInfo.groupId,
|
||||||
|
groupMemberId = groupMember.groupMemberId,
|
||||||
|
itemId = itemId
|
||||||
|
)
|
||||||
|
deletedChatItem = r?.first
|
||||||
|
toChatItem = r?.second
|
||||||
|
} else {
|
||||||
|
val r = chatModel.controller.apiDeleteChatItem(
|
||||||
|
type = cInfo.chatType,
|
||||||
|
id = cInfo.apiId,
|
||||||
|
itemId = itemId,
|
||||||
|
mode = mode
|
||||||
|
)
|
||||||
|
deletedChatItem = r?.deletedChatItem?.chatItem
|
||||||
|
toChatItem = r?.toChatItem?.chatItem
|
||||||
|
}
|
||||||
|
if (toChatItem == null && deletedChatItem != null) {
|
||||||
|
chatModel.removeChatItem(cInfo, deletedChatItem)
|
||||||
|
} else if (toChatItem != null) {
|
||||||
|
chatModel.upsertChatItem(cInfo, toChatItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
receiveFile = { fileId ->
|
receiveFile = { fileId ->
|
||||||
withApi { chatModel.controller.receiveFile(fileId) }
|
withApi { chatModel.controller.receiveFile(user, fileId) }
|
||||||
|
},
|
||||||
|
cancelFile = { fileId ->
|
||||||
|
withApi { chatModel.controller.cancelFile(user, fileId) }
|
||||||
},
|
},
|
||||||
joinGroup = { groupId ->
|
joinGroup = { groupId ->
|
||||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||||
@@ -200,19 +253,24 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
acceptFeature = { contact, feature, param ->
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.allowFeatureToContact(contact, feature, param)
|
||||||
|
}
|
||||||
|
},
|
||||||
addMembers = { groupInfo ->
|
addMembers = { groupInfo ->
|
||||||
hideKeyboard(view)
|
hideKeyboard(view)
|
||||||
withApi {
|
withApi {
|
||||||
setGroupMembers(groupInfo, chatModel)
|
setGroupMembers(groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
AddGroupMembersView(groupInfo, chatModel, close)
|
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
markRead = { range, unreadCountAfter ->
|
markRead = { range, unreadCountAfter ->
|
||||||
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
|
||||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
withApi {
|
withBGApi {
|
||||||
chatModel.controller.apiChatRead(
|
chatModel.controller.apiChatRead(
|
||||||
chat.chatInfo.chatType,
|
chat.chatInfo.chatType,
|
||||||
chat.chatInfo.apiId,
|
chat.chatInfo.apiId,
|
||||||
@@ -228,24 +286,24 @@ fun ChatView(chatModel: ChatModel) {
|
|||||||
apiFindMessages(c.chatInfo, chatModel, value)
|
apiFindMessages(c.chatInfo, chatModel, value)
|
||||||
searchText.value = value
|
searchText.value = value
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatLayout(
|
fun ChatLayout(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
composeView: (@Composable () -> Unit),
|
composeView: (@Composable () -> Unit),
|
||||||
attachmentOption: MutableState<AttachmentOption?>,
|
attachmentOption: MutableState<AttachmentOption?>,
|
||||||
scope: CoroutineScope,
|
|
||||||
attachmentBottomSheetState: ModalBottomSheetState,
|
attachmentBottomSheetState: ModalBottomSheetState,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
searchValue: State<String>,
|
searchValue: State<String>,
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
chatModelIncognito: Boolean,
|
chatModelIncognito: Boolean,
|
||||||
back: () -> Unit,
|
back: () -> Unit,
|
||||||
info: () -> Unit,
|
info: () -> Unit,
|
||||||
@@ -253,18 +311,22 @@ fun ChatLayout(
|
|||||||
loadPrevMessages: (ChatInfo) -> Unit,
|
loadPrevMessages: (ChatInfo) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
startCall: (CallMediaType) -> Unit,
|
startCall: (CallMediaType) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||||
addMembers: (GroupInfo) -> Unit,
|
addMembers: (GroupInfo) -> Unit,
|
||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||||
onSearchValueChanged: (String) -> Unit,
|
onSearchValueChanged: (String) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
val scope = rememberCoroutineScope()
|
||||||
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
@@ -292,9 +354,9 @@ fun ChatLayout(
|
|||||||
) { contentPadding ->
|
) { contentPadding ->
|
||||||
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
BoxWithConstraints(Modifier.fillMaxHeight().padding(contentPadding)) {
|
||||||
ChatItemsList(
|
ChatItemsList(
|
||||||
user, chat, unreadCount, composeState, chatItems, searchValue,
|
chat, unreadCount, composeState, chatItems, searchValue,
|
||||||
useLinkPreviews, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
useLinkPreviews, linkMode, chatModelIncognito, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||||
receiveFile, joinGroup, acceptCall, markRead, setFloatingButton
|
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, markRead, setFloatingButton, onComposed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +376,7 @@ fun ChatInfoToolbar(
|
|||||||
onSearchValueChanged: (String) -> Unit,
|
onSearchValueChanged: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showMenu by rememberSaveable { mutableStateOf(false) }
|
val showMenu = rememberSaveable { mutableStateOf(false) }
|
||||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
val onBackClicked = {
|
val onBackClicked = {
|
||||||
if (!showSearch) {
|
if (!showSearch) {
|
||||||
@@ -328,34 +390,34 @@ fun ChatInfoToolbar(
|
|||||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), Icons.Outlined.Search, onClick = {
|
ItemAction(stringResource(android.R.string.search_go).capitalize(Locale.current), painterResource(R.drawable.ic_search), onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
showSearch = true
|
showSearch = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat.chatInfo is ChatInfo.Direct) {
|
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({
|
IconButton({
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
startCall(CallMediaType.Audio)
|
startCall(CallMediaType.Audio)
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Outlined.Phone, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_call_500), stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), Icons.Outlined.Videocam, onClick = {
|
ItemAction(stringResource(R.string.icon_descr_video_call).capitalize(Locale.current), painterResource(R.drawable.ic_videocam), onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
startCall(CallMediaType.Video)
|
startCall(CallMediaType.Video)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({
|
IconButton({
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
addMembers(chat.chatInfo.groupInfo)
|
addMembers(chat.chatInfo.groupInfo)
|
||||||
}) {
|
}) {
|
||||||
Icon(Icons.Outlined.PersonAdd, stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_person_add_500), stringResource(R.string.icon_descr_add_members), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,9 +425,9 @@ fun ChatInfoToolbar(
|
|||||||
menuItems.add {
|
menuItems.add {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
if (ntfsEnabled.value) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||||
if (ntfsEnabled.value) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
if (ntfsEnabled.value) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||||
onClick = {
|
onClick = {
|
||||||
showMenu = false
|
showMenu.value = false
|
||||||
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
// Just to make a delay before changing state of ntfsEnabled, otherwise it will redraw menu item with new value before closing the menu
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(200)
|
delay(200)
|
||||||
@@ -376,8 +438,8 @@ fun ChatInfoToolbar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showMenu = true }) {
|
IconButton({ showMenu.value = true }) {
|
||||||
Icon(Icons.Default.MoreVert, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
Icon(MoreVertFilled, stringResource(R.string.icon_descr_more_button), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,18 +455,14 @@ fun ChatInfoToolbar(
|
|||||||
Divider(Modifier.padding(top = AppBarHeight))
|
Divider(Modifier.padding(top = AppBarHeight))
|
||||||
|
|
||||||
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu,
|
|
||||||
onDismissRequest = { showMenu = false },
|
|
||||||
Modifier.widthIn(min = 220.dp)
|
|
||||||
) {
|
|
||||||
menuItems.forEach { it() }
|
menuItems.forEach { it() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -417,10 +475,15 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
|||||||
Modifier.padding(start = 8.dp),
|
Modifier.padding(start = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis
|
ContactVerifiedShield()
|
||||||
)
|
}
|
||||||
|
Text(
|
||||||
|
cInfo.displayName, fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.localAlias.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
cInfo.fullName,
|
cInfo.fullName,
|
||||||
@@ -431,6 +494,11 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactVerifiedShield() {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
|
||||||
|
|
||||||
val CIListStateSaver = run {
|
val CIListStateSaver = run {
|
||||||
@@ -445,28 +513,29 @@ val CIListStateSaver = run {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BoxWithConstraintsScope.ChatItemsList(
|
fun BoxWithConstraintsScope.ChatItemsList(
|
||||||
user: User,
|
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
unreadCount: State<Int>,
|
unreadCount: State<Int>,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
searchValue: State<String>,
|
searchValue: State<String>,
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
chatModelIncognito: Boolean,
|
chatModelIncognito: Boolean,
|
||||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||||
loadPrevMessages: (ChatInfo) -> Unit,
|
loadPrevMessages: (ChatInfo) -> Unit,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||||
|
onComposed: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val uriHandler = LocalUriHandler.current
|
ScrollToBottom(chat.id, listState, chatItems)
|
||||||
val cxt = LocalContext.current
|
|
||||||
ScrollToBottom(chat.id, listState)
|
|
||||||
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
var prevSearchEmptiness by rememberSaveable { mutableStateOf(searchValue.value.isEmpty()) }
|
||||||
// Scroll to bottom when search value changes from something to nothing and back
|
// Scroll to bottom when search value changes from something to nothing and back
|
||||||
LaunchedEffect(searchValue.value.isEmpty()) {
|
LaunchedEffect(searchValue.value.isEmpty()) {
|
||||||
@@ -485,7 +554,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
val reversedChatItems by remember { derivedStateOf { chatItems.reversed() } }
|
val reversedChatItems by remember { derivedStateOf { chatItems.reversed().toList() } }
|
||||||
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
|
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
|
||||||
val scrollToItem: (Long) -> Unit = { itemId: Long ->
|
val scrollToItem: (Long) -> Unit = { itemId: Long ->
|
||||||
val index = reversedChatItems.indexOfFirst { it.id == itemId }
|
val index = reversedChatItems.indexOfFirst { it.id == itemId }
|
||||||
@@ -493,8 +562,23 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
var stopListening = false
|
||||||
|
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { !stopListening }
|
||||||
|
.collect {
|
||||||
|
onComposed()
|
||||||
|
stopListening = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffectOnGone(
|
||||||
|
whenGone = {
|
||||||
|
VideoPlayer.releaseAll()
|
||||||
|
}
|
||||||
|
)
|
||||||
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
|
||||||
itemsIndexed(reversedChatItems) { i, cItem ->
|
itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem ->
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
// Makes horizontal and vertical scrolling to coexist nicely.
|
// Makes horizontal and vertical scrolling to coexist nicely.
|
||||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||||
@@ -512,10 +596,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
if (dismissState.isAnimationRunning && (swipedToStart || swipedToEnd)) {
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (composeState.value.editing) {
|
if (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) {
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
if (composeState.value.editing) {
|
||||||
} else {
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||||
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -555,11 +641,11 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.size(42.dp))
|
Spacer(Modifier.size(42.dp))
|
||||||
}
|
}
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, showMember = showMember, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, showMember = showMember, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
Box(Modifier.padding(start = 104.dp, end = 12.dp).then(swipeableModifier)) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = {}, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // direct message
|
} else { // direct message
|
||||||
@@ -570,7 +656,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
end = if (sent) 12.dp else 76.dp,
|
end = if (sent) 12.dp else 76.dp,
|
||||||
).then(swipeableModifier)
|
).then(swipeableModifier)
|
||||||
) {
|
) {
|
||||||
ChatItemView(user, chat.chatInfo, cItem, composeState, cxt, uriHandler, provider, chatModelIncognito = chatModelIncognito, useLinkPreviews = useLinkPreviews, deleteMessage = deleteMessage, receiveFile = receiveFile, joinGroup = joinGroup, acceptCall = acceptCall, scrollToItem = scrollToItem)
|
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, scrollToItem = scrollToItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +675,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: List<ChatItem>) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Helps to scroll to bottom after moving from Group to Direct chat
|
// Helps to scroll to bottom after moving from Group to Direct chat
|
||||||
// and prevents scrolling to bottom on orientation change
|
// and prevents scrolling to bottom on orientation change
|
||||||
@@ -601,6 +687,31 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState) {
|
|||||||
// Don't autoscroll next time until it will be needed
|
// Don't autoscroll next time until it will be needed
|
||||||
shouldAutoScroll = false to chatId
|
shouldAutoScroll = false to chatId
|
||||||
}
|
}
|
||||||
|
val scrollDistance = with(LocalDensity.current) { -39.dp.toPx() }
|
||||||
|
/*
|
||||||
|
* Since we use key with each item in LazyColumn, LazyColumn will not autoscroll to bottom item. We need to do it ourselves.
|
||||||
|
* When the first visible item (from bottom) is visible (even partially) we can autoscroll to 0 item. Or just scrollBy small distance otherwise
|
||||||
|
* */
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { chatItems.lastOrNull()?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.key != it }
|
||||||
|
.collect {
|
||||||
|
try {
|
||||||
|
if (listState.firstVisibleItemIndex == 0) {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
} else {
|
||||||
|
listState.animateScrollBy(scrollDistance)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
/**
|
||||||
|
* When you tap and hold a finger on a lazy column with chatItems, and then you receive a message,
|
||||||
|
* this coroutine will be canceled with the message "Current mutation had a higher priority" because of animatedScroll.
|
||||||
|
* Which breaks auto-scrolling to bottom. So just ignoring the exception
|
||||||
|
* */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -671,36 +782,27 @@ fun BoxWithConstraintsScope.FloatingButtons(
|
|||||||
}
|
}
|
||||||
val showButtonWithCounter = topUnreadCount > 0
|
val showButtonWithCounter = topUnreadCount > 0
|
||||||
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
val height = with(LocalDensity.current) { maxHeight.toPx() }
|
||||||
var showDropDown by remember { mutableStateOf(false) }
|
val showDropDown = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
TopEndFloatingButton(
|
TopEndFloatingButton(
|
||||||
Modifier.padding(end = 16.dp, top = 24.dp).align(Alignment.TopEnd),
|
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp).align(Alignment.TopEnd),
|
||||||
topUnreadCount,
|
topUnreadCount,
|
||||||
showButtonWithCounter,
|
showButtonWithCounter,
|
||||||
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
onClick = { scope.launch { listState.animateScrollBy(height) } },
|
||||||
onLongClick = { showDropDown = true }
|
onLongClick = { showDropDown.value = true }
|
||||||
)
|
)
|
||||||
|
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showDropDown, offset = DpOffset(maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
|
||||||
expanded = showDropDown,
|
ItemAction(
|
||||||
onDismissRequest = { showDropDown = false },
|
generalGetString(R.string.mark_read),
|
||||||
Modifier.width(220.dp),
|
painterResource(R.drawable.ic_check),
|
||||||
offset = DpOffset(maxWidth - 16.dp, 24.dp + fabSize)
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
markRead(
|
markRead(
|
||||||
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
CC.ItemRange(minUnreadItemId, chatItems[chatItems.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
|
||||||
bottomUnreadCount
|
bottomUnreadCount
|
||||||
)
|
)
|
||||||
showDropDown = false
|
showDropDown.value = false
|
||||||
}
|
})
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
generalGetString(R.string.mark_read),
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,7 +819,7 @@ fun PreloadItems(
|
|||||||
.map {
|
.map {
|
||||||
val totalItemsNumber = it.totalItemsCount
|
val totalItemsNumber = it.totalItemsCount
|
||||||
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
val lastVisibleItemIndex = (it.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1
|
||||||
if (lastVisibleItemIndex > (totalItemsNumber - remaining))
|
if (lastVisibleItemIndex > (totalItemsNumber - remaining) && totalItemsNumber >= ChatPagination.INITIAL_COUNT)
|
||||||
totalItemsNumber
|
totalItemsNumber
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
@@ -797,7 +899,7 @@ private fun bottomEndFloatingButton(
|
|||||||
modifier = Modifier.size(48.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.KeyboardArrowDown,
|
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colors.primary
|
tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
@@ -825,21 +927,26 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class ProviderMedia {
|
||||||
|
data class Image(val uri: Uri, val image: Bitmap): ProviderMedia()
|
||||||
|
data class Video(val uri: Uri, val preview: String): ProviderMedia()
|
||||||
|
}
|
||||||
|
|
||||||
private fun providerForGallery(
|
private fun providerForGallery(
|
||||||
listStateIndex: Int,
|
listStateIndex: Int,
|
||||||
chatItems: List<ChatItem>,
|
chatItems: List<ChatItem>,
|
||||||
cItemId: Long,
|
cItemId: Long,
|
||||||
scrollTo: (Int) -> Unit
|
scrollTo: (Int) -> Unit
|
||||||
): ImageGalleryProvider {
|
): ImageGalleryProvider {
|
||||||
fun canShowImage(item: ChatItem): Boolean =
|
fun canShowMedia(item: ChatItem): Boolean =
|
||||||
item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null
|
(item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null)
|
||||||
|
|
||||||
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
fun item(skipInternalIndex: Int, initialChatId: Long): Pair<Int, ChatItem>? {
|
||||||
var processedInternalIndex = -skipInternalIndex.sign
|
var processedInternalIndex = -skipInternalIndex.sign
|
||||||
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId }
|
||||||
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) {
|
||||||
val item = chatItems[chatItemsIndex]
|
val item = chatItems[chatItemsIndex]
|
||||||
if (canShowImage(item)) {
|
if (canShowMedia(item)) {
|
||||||
processedInternalIndex += skipInternalIndex.sign
|
processedInternalIndex += skipInternalIndex.sign
|
||||||
}
|
}
|
||||||
if (processedInternalIndex == skipInternalIndex) {
|
if (processedInternalIndex == skipInternalIndex) {
|
||||||
@@ -853,16 +960,28 @@ private fun providerForGallery(
|
|||||||
var initialChatId = cItemId
|
var initialChatId = cItemId
|
||||||
return object: ImageGalleryProvider {
|
return object: ImageGalleryProvider {
|
||||||
override val initialIndex: Int = initialIndex
|
override val initialIndex: Int = initialIndex
|
||||||
override val totalImagesSize = mutableStateOf(Int.MAX_VALUE)
|
override val totalMediaSize = mutableStateOf(Int.MAX_VALUE)
|
||||||
override fun getImage(index: Int): Pair<Bitmap, Uri>? {
|
override fun getMedia(index: Int): ProviderMedia? {
|
||||||
val internalIndex = initialIndex - index
|
val internalIndex = initialIndex - index
|
||||||
val file = item(internalIndex, initialChatId)?.second?.file
|
val item = item(internalIndex, initialChatId)?.second ?: return null
|
||||||
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file)
|
return when (item.content.msgContent) {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, file)
|
is MsgContent.MCImage -> {
|
||||||
return if (imageBitmap != null && filePath != null) {
|
val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file)
|
||||||
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||||
imageBitmap to uri
|
if (imageBitmap != null && filePath != null) {
|
||||||
} else null
|
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||||
|
ProviderMedia.Image(uri, imageBitmap)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
is MsgContent.MCVideo -> {
|
||||||
|
val filePath = getLoadedFilePath(SimplexApp.context, item.file)
|
||||||
|
if (filePath != null) {
|
||||||
|
val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath))
|
||||||
|
ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image)
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun currentPageChanged(index: Int) {
|
override fun currentPageChanged(index: Int) {
|
||||||
@@ -874,7 +993,7 @@ private fun providerForGallery(
|
|||||||
|
|
||||||
override fun scrollToStart() {
|
override fun scrollToStart() {
|
||||||
initialIndex = 0
|
initialIndex = 0
|
||||||
initialChatId = chatItems.first { canShowImage(it) }.id
|
initialChatId = chatItems.first { canShowMedia(it) }.id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDismiss(index: Int) {
|
override fun onDismiss(index: Int) {
|
||||||
@@ -931,7 +1050,6 @@ fun PreviewChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -941,11 +1059,11 @@ fun PreviewChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
chatModelIncognito = false,
|
chatModelIncognito = false,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
@@ -953,13 +1071,16 @@ fun PreviewChatLayout() {
|
|||||||
loadPrevMessages = { _ -> },
|
loadPrevMessages = { _ -> },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
|
acceptFeature = { _, _, _ -> },
|
||||||
addMembers = { _ -> },
|
addMembers = { _ -> },
|
||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,7 +1110,6 @@ fun PreviewGroupChatLayout() {
|
|||||||
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
val unreadCount = remember { mutableStateOf(chatItems.count { it.isRcvNew }) }
|
||||||
val searchValue = remember { mutableStateOf("") }
|
val searchValue = remember { mutableStateOf("") }
|
||||||
ChatLayout(
|
ChatLayout(
|
||||||
user = User.sampleData,
|
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Group.sampleData,
|
chatInfo = ChatInfo.Group.sampleData,
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
@@ -999,11 +1119,11 @@ fun PreviewGroupChatLayout() {
|
|||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
composeView = {},
|
composeView = {},
|
||||||
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
attachmentOption = remember { mutableStateOf<AttachmentOption?>(null) },
|
||||||
scope = rememberCoroutineScope(),
|
|
||||||
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
attachmentBottomSheetState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden),
|
||||||
chatItems = chatItems,
|
chatItems = chatItems,
|
||||||
searchValue,
|
searchValue,
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
chatModelIncognito = false,
|
chatModelIncognito = false,
|
||||||
back = {},
|
back = {},
|
||||||
info = {},
|
info = {},
|
||||||
@@ -1011,13 +1131,16 @@ fun PreviewGroupChatLayout() {
|
|||||||
loadPrevMessages = { _ -> },
|
loadPrevMessages = { _ -> },
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
startCall = {},
|
startCall = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
|
acceptFeature = { _, _, _ -> },
|
||||||
addMembers = { _ -> },
|
addMembers = { _ -> },
|
||||||
markRead = { _, _ -> },
|
markRead = { _, _ -> },
|
||||||
changeNtfsState = { _, _ -> },
|
changeNtfsState = { _, _ -> },
|
||||||
onSearchValueChanged = {},
|
onSearchValueChanged = {},
|
||||||
|
onComposed = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boolean) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.height(60.dp)
|
.height(60.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(SentColorLight),
|
.background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 4.dp, end = 2.dp)
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
@@ -37,7 +36,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
|
|||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
|
|||||||
@@ -4,26 +4,28 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING_HALF
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.item.SentColorLight
|
import chat.simplex.app.views.helpers.UploadContent
|
||||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
import chat.simplex.app.views.helpers.base64ToBitmap
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
fun ComposeImageView(media: ComposePreview.MediaPreview, cancelImages: () -> Unit, cancelEnabled: Boolean) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(SentColorLight),
|
.background(sentColor),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
LazyRow(
|
LazyRow(
|
||||||
@@ -31,19 +33,38 @@ fun ComposeImageView(images: List<String>, cancelImages: () -> Unit, cancelEnabl
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF),
|
||||||
) {
|
) {
|
||||||
items(images.size) { index ->
|
itemsIndexed(media.images) { index, item ->
|
||||||
val imageBitmap = base64ToBitmap(images[index]).asImageBitmap()
|
val content = media.content[index]
|
||||||
Image(
|
if (content is UploadContent.Video) {
|
||||||
imageBitmap,
|
Box(contentAlignment = Alignment.Center) {
|
||||||
"preview image",
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
Image(
|
||||||
)
|
imageBitmap,
|
||||||
|
"preview video",
|
||||||
|
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_videocam_filled),
|
||||||
|
"preview video",
|
||||||
|
Modifier
|
||||||
|
.size(20.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val imageBitmap = base64ToBitmap(item).asImageBitmap()
|
||||||
|
Image(
|
||||||
|
imageBitmap,
|
||||||
|
"preview image",
|
||||||
|
modifier = Modifier.widthIn(max = 80.dp).height(60.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cancelEnabled) {
|
if (cancelEnabled) {
|
||||||
IconButton(onClick = cancelImages) {
|
IconButton(onClick = cancelImages) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
contentDescription = stringResource(R.string.icon_descr_cancel_image_preview),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
|
@file:UseSerializers(UriSerializer::class)
|
||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
import ComposeFileView
|
import ComposeFileView
|
||||||
|
import ComposeVoiceView
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.*
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Bitmap
|
import android.graphics.*
|
||||||
import android.graphics.ImageDecoder
|
|
||||||
import android.graphics.ImageDecoder.DecodeException
|
|
||||||
import android.graphics.drawable.AnimatedImageDrawable
|
import android.graphics.drawable.AnimatedImageDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Log
|
import android.webkit.MimeTypeMap
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContract
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.MaterialTheme
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.AttachFile
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.Reply
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -39,17 +35,19 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.*
|
||||||
import kotlinx.serialization.decodeFromString
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class ComposePreview {
|
sealed class ComposePreview {
|
||||||
@Serializable object NoPreview: ComposePreview()
|
@Serializable object NoPreview: ComposePreview()
|
||||||
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
@Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview()
|
||||||
@Serializable class ImagePreview(val images: List<String>): ComposePreview()
|
@Serializable class MediaPreview(val images: List<String>, val content: List<UploadContent>): ComposePreview()
|
||||||
@Serializable class FilePreview(val fileName: String): ComposePreview()
|
@Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview()
|
||||||
|
@Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -59,16 +57,26 @@ sealed class ComposeContextItem {
|
|||||||
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class LiveMessage(
|
||||||
|
val chatItem: ChatItem,
|
||||||
|
val typedMsg: String,
|
||||||
|
val sentMsg: String,
|
||||||
|
val sent: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ComposeState(
|
data class ComposeState(
|
||||||
val message: String = "",
|
val message: String = "",
|
||||||
|
val liveMessage: LiveMessage? = null,
|
||||||
val preview: ComposePreview = ComposePreview.NoPreview,
|
val preview: ComposePreview = ComposePreview.NoPreview,
|
||||||
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem,
|
||||||
val inProgress: Boolean = false,
|
val inProgress: Boolean = false,
|
||||||
val useLinkPreviews: Boolean
|
val useLinkPreviews: Boolean
|
||||||
) {
|
) {
|
||||||
constructor(editingItem: ChatItem, useLinkPreviews: Boolean): this(
|
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
|
||||||
editingItem.content.text,
|
editingItem.content.text,
|
||||||
|
liveMessage,
|
||||||
chatItemPreview(editingItem),
|
chatItemPreview(editingItem),
|
||||||
ComposeContextItem.EditingItem(editingItem),
|
ComposeContextItem.EditingItem(editingItem),
|
||||||
useLinkPreviews = useLinkPreviews
|
useLinkPreviews = useLinkPreviews
|
||||||
@@ -83,16 +91,21 @@ data class ComposeState(
|
|||||||
val sendEnabled: () -> Boolean
|
val sendEnabled: () -> Boolean
|
||||||
get() = {
|
get() = {
|
||||||
val hasContent = when (preview) {
|
val hasContent = when (preview) {
|
||||||
is ComposePreview.ImagePreview -> true
|
is ComposePreview.MediaPreview -> true
|
||||||
|
is ComposePreview.VoicePreview -> true
|
||||||
is ComposePreview.FilePreview -> true
|
is ComposePreview.FilePreview -> true
|
||||||
else -> message.isNotEmpty()
|
else -> message.isNotEmpty() || liveMessage != null
|
||||||
}
|
}
|
||||||
hasContent && !inProgress
|
hasContent && !inProgress
|
||||||
}
|
}
|
||||||
|
val endLiveDisabled: Boolean
|
||||||
|
get() = liveMessage != null && message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
|
||||||
|
|
||||||
val linkPreviewAllowed: Boolean
|
val linkPreviewAllowed: Boolean
|
||||||
get() =
|
get() =
|
||||||
when (preview) {
|
when (preview) {
|
||||||
is ComposePreview.ImagePreview -> false
|
is ComposePreview.MediaPreview -> false
|
||||||
|
is ComposePreview.VoicePreview -> false
|
||||||
is ComposePreview.FilePreview -> false
|
is ComposePreview.FilePreview -> false
|
||||||
else -> useLinkPreviews
|
else -> useLinkPreviews
|
||||||
}
|
}
|
||||||
@@ -103,6 +116,19 @@ data class ComposeState(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val attachmentDisabled: Boolean
|
||||||
|
get() {
|
||||||
|
if (editing || liveMessage != null) return true
|
||||||
|
return when (preview) {
|
||||||
|
ComposePreview.NoPreview -> false
|
||||||
|
is ComposePreview.CLinkPreview -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val empty: Boolean
|
||||||
|
get() = message.isEmpty() && preview is ComposePreview.NoPreview
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
fun saver(): Saver<MutableState<ComposeState>, *> = Saver(
|
||||||
save = { json.encodeToString(serializer(), it.value) },
|
save = { json.encodeToString(serializer(), it.value) },
|
||||||
@@ -113,16 +139,26 @@ data class ComposeState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed class RecordingState {
|
||||||
|
object NotStarted: RecordingState()
|
||||||
|
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
|
||||||
|
class Finished(val filePath: String, val durationMs: Int): RecordingState()
|
||||||
|
|
||||||
|
val filePathNullable: String?
|
||||||
|
get() = (this as? Started)?.filePath
|
||||||
|
}
|
||||||
|
|
||||||
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
fun chatItemPreview(chatItem: ChatItem): ComposePreview {
|
||||||
|
val fileName = chatItem.file?.fileName ?: ""
|
||||||
return when (val mc = chatItem.content.msgContent) {
|
return when (val mc = chatItem.content.msgContent) {
|
||||||
is MsgContent.MCText -> ComposePreview.NoPreview
|
is MsgContent.MCText -> ComposePreview.NoPreview
|
||||||
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview)
|
||||||
is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image))
|
// TODO: include correct type
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCImage -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||||
val fileName = chatItem.file?.fileName ?: ""
|
is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
|
||||||
ComposePreview.FilePreview(fileName)
|
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
|
||||||
}
|
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
|
||||||
else -> ComposePreview.NoPreview
|
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,95 +176,101 @@ fun ComposeView(
|
|||||||
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
val cancelledLinks = rememberSaveable { mutableSetOf<String>() }
|
||||||
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
|
||||||
|
val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
// attachments
|
|
||||||
val chosenContent = rememberSaveable { mutableStateOf<List<UploadContent>>(emptyList()) }
|
|
||||||
val chosenFile = rememberSaveable { mutableStateOf<Uri?>(null) }
|
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
val bitmap: Bitmap? = getBitmapFromUri(uri)
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
if (bitmap != null) {
|
||||||
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
|
||||||
chosenContent.value = listOf(UploadContent.SimpleImage(uri))
|
composeState.value = composeState.value.copy(preview = ComposePreview.MediaPreview(listOf(imagePreview), listOf(UploadContent.SimpleImage(uri))))
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.ImagePreview(listOf(imagePreview)))
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
val cameraPermissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val processPickedImage = { uris: List<Uri>, text: String? ->
|
val processPickedMedia = { uris: List<Uri>, text: String? ->
|
||||||
val content = ArrayList<UploadContent>()
|
val content = ArrayList<UploadContent>()
|
||||||
val imagesPreview = ArrayList<String>()
|
val imagesPreview = ArrayList<String>()
|
||||||
uris.forEach { uri ->
|
uris.forEach { uri ->
|
||||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
var bitmap: Bitmap? = null
|
||||||
val drawable = try {
|
val isImage = MimeTypeMap.getSingleton().getMimeTypeFromExtension(getFileName(SimplexApp.context, uri)?.split(".")?.last())?.contains("image/") == true
|
||||||
ImageDecoder.decodeDrawable(source)
|
when {
|
||||||
} catch (e: DecodeException) {
|
isImage -> {
|
||||||
AlertManager.shared.showAlertMsg(
|
// Image
|
||||||
title = generalGetString(R.string.image_decoding_exception_title),
|
val drawable = getDrawableFromUri(uri)
|
||||||
text = generalGetString(R.string.image_decoding_exception_desc)
|
bitmap = if (drawable != null) getBitmapFromUri(uri) else null
|
||||||
)
|
val isAnimNewApi = Build.VERSION.SDK_INT >= 28 && drawable is AnimatedImageDrawable
|
||||||
Log.e(TAG, "Error while decoding drawable: ${e.stackTraceToString()}")
|
val isAnimOldApi = Build.VERSION.SDK_INT < 28 &&
|
||||||
null
|
(getFileName(SimplexApp.context, uri)?.endsWith(".gif") == true || getFileName(SimplexApp.context, uri)?.endsWith(".webp") == true)
|
||||||
}
|
if (isAnimNewApi || isAnimOldApi) {
|
||||||
var bitmap: Bitmap? = if (drawable != null) ImageDecoder.decodeBitmap(source) else null
|
// It's a gif or webp
|
||||||
if (drawable is AnimatedImageDrawable) {
|
val fileSize = getFileSize(context, uri)
|
||||||
// It's a gif or webp
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
val fileSize = getFileSize(context, uri)
|
content.add(UploadContent.AnimatedImage(uri))
|
||||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
} else {
|
||||||
content.add(UploadContent.AnimatedImage(uri))
|
bitmap = null
|
||||||
} else {
|
AlertManager.shared.showAlertMsg(
|
||||||
bitmap = null
|
generalGetString(R.string.large_file),
|
||||||
AlertManager.shared.showAlertMsg(
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
generalGetString(R.string.large_file),
|
)
|
||||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
}
|
||||||
)
|
} else {
|
||||||
|
content.add(UploadContent.SimpleImage(uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Video
|
||||||
|
val res = getBitmapFromVideo(uri)
|
||||||
|
bitmap = res.preview
|
||||||
|
val durationMs = res.duration
|
||||||
|
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
content.add(UploadContent.SimpleImage(uri))
|
|
||||||
}
|
}
|
||||||
if (bitmap != null) {
|
if (bitmap != null) {
|
||||||
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imagesPreview.isNotEmpty()) {
|
if (imagesPreview.isNotEmpty()) {
|
||||||
chosenContent.value = content
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.MediaPreview(imagesPreview, content))
|
||||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val processPickedFile = { uri: Uri?, text: String? ->
|
val processPickedFile = { uri: Uri?, text: String? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val fileSize = getFileSize(context, uri)
|
val fileSize = getFileSize(context, uri)
|
||||||
if (fileSize != null && fileSize <= MAX_FILE_SIZE) {
|
if (fileSize != null && fileSize <= maxFileSize) {
|
||||||
val fileName = getFileName(SimplexApp.context, uri)
|
val fileName = getFileName(SimplexApp.context, uri)
|
||||||
if (fileName != null) {
|
if (fileName != null) {
|
||||||
chosenFile.value = uri
|
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName, uri))
|
||||||
composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.FilePreview(fileName))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.maximum_supported_file_size), formatBytes(maxFileSize))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) }
|
val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedMedia(it, null) }
|
||||||
val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) }
|
val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedMedia(it, null) }
|
||||||
|
val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedMedia(it, null) }
|
||||||
|
|
||||||
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) }
|
||||||
|
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
|
||||||
|
|
||||||
LaunchedEffect(attachmentOption.value) {
|
LaunchedEffect(attachmentOption.value) {
|
||||||
when (attachmentOption.value) {
|
when (attachmentOption.value) {
|
||||||
AttachmentOption.TakePhoto -> {
|
AttachmentOption.CameraPhoto -> {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
@@ -236,15 +278,23 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
AttachmentOption.PickImage -> {
|
AttachmentOption.GalleryImage -> {
|
||||||
try {
|
try {
|
||||||
galleryLauncher.launch(0)
|
galleryImageLauncher.launch(0)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
galleryLauncherFallback.launch("image/*")
|
galleryImageLauncherFallback.launch("image/*")
|
||||||
}
|
}
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
AttachmentOption.PickFile -> {
|
AttachmentOption.GalleryVideo -> {
|
||||||
|
try {
|
||||||
|
galleryVideoLauncher.launch(0)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
galleryVideoLauncherFallback.launch("video/*")
|
||||||
|
}
|
||||||
|
attachmentOption.value = null
|
||||||
|
}
|
||||||
|
AttachmentOption.File -> {
|
||||||
filesLauncher.launch("*/*")
|
filesLauncher.launch("*/*")
|
||||||
attachmentOption.value = null
|
attachmentOption.value = null
|
||||||
}
|
}
|
||||||
@@ -299,117 +349,170 @@ fun ComposeView(
|
|||||||
cancelledLinks.clear()
|
cancelledLinks.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkLinkPreview(): MsgContent {
|
fun clearState(live: Boolean = false) {
|
||||||
|
if (live) {
|
||||||
|
composeState.value = composeState.value.copy(inProgress = false)
|
||||||
|
} else {
|
||||||
|
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||||
|
resetLinkPreview()
|
||||||
|
}
|
||||||
|
recState.value = RecordingState.NotStarted
|
||||||
|
textStyle.value = smallFont
|
||||||
|
chatModel.removeLiveDummy()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteUnusedFiles() {
|
||||||
|
chatModel.filesToDelete.forEach { it.delete() }
|
||||||
|
chatModel.filesToDelete.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: String? = null, live: Boolean = false): ChatItem? {
|
||||||
|
val aChatItem = chatModel.controller.apiSendMessage(
|
||||||
|
type = cInfo.chatType,
|
||||||
|
id = cInfo.apiId,
|
||||||
|
file = file,
|
||||||
|
quotedItemId = quoted,
|
||||||
|
mc = mc,
|
||||||
|
live = live
|
||||||
|
)
|
||||||
|
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
||||||
|
return aChatItem?.chatItem
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
suspend fun sendMessageAsync(text: String?, live: Boolean): ChatItem? {
|
||||||
|
val cInfo = chat.chatInfo
|
||||||
val cs = composeState.value
|
val cs = composeState.value
|
||||||
return when (val composePreview = cs.preview) {
|
var sent: ChatItem?
|
||||||
is ComposePreview.CLinkPreview -> {
|
val msgText = text ?: cs.message
|
||||||
val url = parseMessage(cs.message)
|
|
||||||
val lp = composePreview.linkPreview
|
fun sending() {
|
||||||
if (lp != null && url == lp.uri) {
|
composeState.value = composeState.value.copy(inProgress = true)
|
||||||
MsgContent.MCLink(cs.message, preview = lp)
|
}
|
||||||
} else {
|
|
||||||
MsgContent.MCText(cs.message)
|
fun checkLinkPreview(): MsgContent {
|
||||||
|
return when (val composePreview = cs.preview) {
|
||||||
|
is ComposePreview.CLinkPreview -> {
|
||||||
|
val url = parseMessage(msgText)
|
||||||
|
val lp = composePreview.linkPreview
|
||||||
|
if (lp != null && url == lp.uri) {
|
||||||
|
MsgContent.MCLink(msgText, preview = lp)
|
||||||
|
} else {
|
||||||
|
MsgContent.MCText(msgText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> MsgContent.MCText(msgText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
||||||
|
return when (msgContent) {
|
||||||
|
is MsgContent.MCText -> checkLinkPreview()
|
||||||
|
is MsgContent.MCLink -> checkLinkPreview()
|
||||||
|
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
|
||||||
|
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
|
||||||
|
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
|
||||||
|
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
|
||||||
|
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateMessage(ei: ChatItem, cInfo: ChatInfo, live: Boolean): ChatItem? {
|
||||||
|
val oldMsgContent = ei.content.msgContent
|
||||||
|
if (oldMsgContent != null) {
|
||||||
|
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
||||||
|
type = cInfo.chatType,
|
||||||
|
id = cInfo.apiId,
|
||||||
|
itemId = ei.meta.itemId,
|
||||||
|
mc = updateMsgContent(oldMsgContent),
|
||||||
|
live = live
|
||||||
|
)
|
||||||
|
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
||||||
|
return updatedItem?.chatItem
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val liveMessage = cs.liveMessage
|
||||||
|
if (!live) {
|
||||||
|
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
|
||||||
|
sending()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||||
|
val ei = cs.contextItem.chatItem
|
||||||
|
sent = updateMessage(ei, cInfo, live)
|
||||||
|
} else if (liveMessage != null && liveMessage.sent) {
|
||||||
|
sent = updateMessage(liveMessage.chatItem, cInfo, live)
|
||||||
|
} else {
|
||||||
|
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||||
|
val files: ArrayList<String> = ArrayList()
|
||||||
|
when (val preview = cs.preview) {
|
||||||
|
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
|
||||||
|
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
||||||
|
is ComposePreview.MediaPreview -> {
|
||||||
|
preview.content.forEachIndexed { index, it ->
|
||||||
|
val file = when (it) {
|
||||||
|
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
||||||
|
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
||||||
|
is UploadContent.Video -> saveFileFromUri(context, it.uri)
|
||||||
|
}
|
||||||
|
if (file != null) {
|
||||||
|
files.add(file)
|
||||||
|
if (it is UploadContent.Video) {
|
||||||
|
msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration))
|
||||||
|
} else {
|
||||||
|
msgs.add(MsgContent.MCImage(if (preview.content.lastIndex == index) msgText else "", preview.images[index]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is ComposePreview.VoicePreview -> {
|
||||||
|
val tmpFile = File(preview.voice)
|
||||||
|
AudioPlayer.stop(tmpFile.absolutePath)
|
||||||
|
val actualFile = File(getAppFilePath(SimplexApp.context, tmpFile.name.replaceAfter(RecorderNative.extension, "")))
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Files.move(tmpFile.toPath(), actualFile.toPath())
|
||||||
|
}
|
||||||
|
files.add(actualFile.name)
|
||||||
|
deleteUnusedFiles()
|
||||||
|
msgs.add(MsgContent.MCVoice(if (msgs.isEmpty()) msgText else "", preview.durationMs / 1000))
|
||||||
|
}
|
||||||
|
is ComposePreview.FilePreview -> {
|
||||||
|
val file = saveFileFromUri(context, preview.uri)
|
||||||
|
if (file != null) {
|
||||||
|
files.add((file))
|
||||||
|
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) msgText else ""))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> MsgContent.MCText(cs.message)
|
val quotedItemId: Long? = when (cs.contextItem) {
|
||||||
|
is ComposeContextItem.QuotedItem -> cs.contextItem.chatItem.id
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
sent = null
|
||||||
|
msgs.forEachIndexed { index, content ->
|
||||||
|
if (index > 0) delay(100)
|
||||||
|
sent = send(cInfo, content, if (index == 0) quotedItemId else null, files.getOrNull(index),
|
||||||
|
if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (sent == null &&
|
||||||
|
(cs.preview is ComposePreview.MediaPreview ||
|
||||||
|
cs.preview is ComposePreview.FilePreview ||
|
||||||
|
cs.preview is ComposePreview.VoicePreview)
|
||||||
|
) {
|
||||||
|
sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
clearState(live)
|
||||||
|
return sent
|
||||||
fun updateMsgContent(msgContent: MsgContent): MsgContent {
|
|
||||||
val cs = composeState.value
|
|
||||||
return when (msgContent) {
|
|
||||||
is MsgContent.MCText -> checkLinkPreview()
|
|
||||||
is MsgContent.MCLink -> checkLinkPreview()
|
|
||||||
is MsgContent.MCImage -> MsgContent.MCImage(cs.message, image = msgContent.image)
|
|
||||||
is MsgContent.MCFile -> MsgContent.MCFile(cs.message)
|
|
||||||
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = cs.message, json = msgContent.json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearState() {
|
|
||||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
|
||||||
textStyle.value = smallFont
|
|
||||||
chosenContent.value = emptyList()
|
|
||||||
chosenFile.value = null
|
|
||||||
linkUrl.value = null
|
|
||||||
prevLinkUrl.value = null
|
|
||||||
pendingLinkUrl.value = null
|
|
||||||
cancelledLinks.clear()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendMessage() {
|
fun sendMessage() {
|
||||||
composeState.value = composeState.value.copy(inProgress = true)
|
withBGApi {
|
||||||
val cInfo = chat.chatInfo
|
sendMessageAsync(null, false)
|
||||||
val cs = composeState.value
|
|
||||||
when (val contextItem = cs.contextItem) {
|
|
||||||
is ComposeContextItem.EditingItem -> {
|
|
||||||
val ei = contextItem.chatItem
|
|
||||||
val oldMsgContent = ei.content.msgContent
|
|
||||||
if (oldMsgContent != null) {
|
|
||||||
withApi {
|
|
||||||
val updatedItem = chatModel.controller.apiUpdateChatItem(
|
|
||||||
type = cInfo.chatType,
|
|
||||||
id = cInfo.apiId,
|
|
||||||
itemId = ei.meta.itemId,
|
|
||||||
mc = updateMsgContent(oldMsgContent)
|
|
||||||
)
|
|
||||||
if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem)
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
|
||||||
val files: ArrayList<String> = ArrayList()
|
|
||||||
when (val preview = cs.preview) {
|
|
||||||
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(cs.message))
|
|
||||||
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
|
|
||||||
is ComposePreview.ImagePreview -> {
|
|
||||||
chosenContent.value.forEachIndexed { index, it ->
|
|
||||||
val file = when (it) {
|
|
||||||
is UploadContent.SimpleImage -> saveImage(context, it.uri)
|
|
||||||
is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri)
|
|
||||||
}
|
|
||||||
if (file != null) {
|
|
||||||
files.add(file)
|
|
||||||
msgs.add(MsgContent.MCImage(if (msgs.isEmpty()) cs.message else "", preview.images[index]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is ComposePreview.FilePreview -> {
|
|
||||||
val chosenFileVal = chosenFile.value
|
|
||||||
if (chosenFileVal != null) {
|
|
||||||
val file = saveFileFromUri(context, chosenFileVal)
|
|
||||||
if (file != null) {
|
|
||||||
files.add((file))
|
|
||||||
msgs.add(MsgContent.MCFile(if (msgs.isEmpty()) cs.message else ""))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val quotedItemId: Long? = when (contextItem) {
|
|
||||||
is ComposeContextItem.QuotedItem -> contextItem.chatItem.id
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
if (msgs.isNotEmpty()) {
|
|
||||||
withApi {
|
|
||||||
msgs.forEachIndexed { index, content ->
|
|
||||||
if (index > 0) delay(100)
|
|
||||||
val aChatItem = chatModel.controller.apiSendMessage(
|
|
||||||
type = cInfo.chatType,
|
|
||||||
id = cInfo.apiId,
|
|
||||||
file = files.getOrNull(index),
|
|
||||||
quotedItemId = if (index == 0) quotedItemId else null,
|
|
||||||
mc = content
|
|
||||||
)
|
|
||||||
if (aChatItem != null) chatModel.addChatItem(cInfo, aChatItem.chatItem)
|
|
||||||
}
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clearState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +529,19 @@ fun ComposeView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onAudioAdded(filePath: String, durationMs: Int, finished: Boolean) {
|
||||||
|
val file = File(filePath)
|
||||||
|
chatModel.filesToDelete.add(file)
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.VoicePreview(filePath, durationMs, finished))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun allowVoiceToContact() {
|
||||||
|
val contact = (chat.chatInfo as ChatInfo.Direct?)?.contact ?: return
|
||||||
|
withApi {
|
||||||
|
chatModel.controller.allowFeatureToContact(contact, ChatFeature.Voice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun cancelLinkPreview() {
|
fun cancelLinkPreview() {
|
||||||
val uri = composeState.value.linkPreview?.uri
|
val uri = composeState.value.linkPreview?.uri
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
@@ -437,12 +553,70 @@ fun ComposeView(
|
|||||||
|
|
||||||
fun cancelImages() {
|
fun cancelImages() {
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
chosenContent.value = emptyList()
|
}
|
||||||
|
|
||||||
|
fun cancelVoice() {
|
||||||
|
val filePath = recState.value.filePathNullable
|
||||||
|
recState.value = RecordingState.NotStarted
|
||||||
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
|
withBGApi {
|
||||||
|
RecorderNative.stopRecording?.invoke()
|
||||||
|
AudioPlayer.stop(filePath)
|
||||||
|
filePath?.let { File(it).delete() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelFile() {
|
fun cancelFile() {
|
||||||
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
|
||||||
chosenFile.value = null
|
}
|
||||||
|
|
||||||
|
fun truncateToWords(s: String): String {
|
||||||
|
var acc = ""
|
||||||
|
val word = StringBuilder()
|
||||||
|
for (c in s) {
|
||||||
|
if (c.isLetter() || c.isDigit()) {
|
||||||
|
word.append(c)
|
||||||
|
} else {
|
||||||
|
acc = acc + word.toString() + c
|
||||||
|
word.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun sendLiveMessage() {
|
||||||
|
val cs = composeState.value
|
||||||
|
val typedMsg = cs.message
|
||||||
|
if ((cs.sendEnabled() || cs.contextItem is ComposeContextItem.QuotedItem) && (cs.liveMessage == null || !cs.liveMessage?.sent)) {
|
||||||
|
val ci = sendMessageAsync(typedMsg, live = true)
|
||||||
|
if (ci != null) {
|
||||||
|
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = typedMsg, sent = true))
|
||||||
|
}
|
||||||
|
} else if (cs.liveMessage == null) {
|
||||||
|
val cItem = chatModel.addLiveDummy(chat.chatInfo)
|
||||||
|
composeState.value = composeState.value.copy(liveMessage = LiveMessage(cItem, typedMsg = typedMsg, sentMsg = typedMsg, sent = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun liveMessageToSend(lm: LiveMessage, t: String): String? {
|
||||||
|
val s = if (t != lm.typedMsg) truncateToWords(t) else t
|
||||||
|
return if (s != lm.sentMsg) s else null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateLiveMessage() {
|
||||||
|
val typedMsg = composeState.value.message
|
||||||
|
val liveMessage = composeState.value.liveMessage
|
||||||
|
if (liveMessage != null) {
|
||||||
|
val sentMsg = liveMessageToSend(liveMessage, typedMsg)
|
||||||
|
if (sentMsg != null) {
|
||||||
|
val ci = sendMessageAsync(sentMsg, live = true)
|
||||||
|
if (ci != null) {
|
||||||
|
composeState.value = composeState.value.copy(liveMessage = LiveMessage(ci, typedMsg = typedMsg, sentMsg = sentMsg, sent = true))
|
||||||
|
}
|
||||||
|
} else if (liveMessage.typedMsg != typedMsg) {
|
||||||
|
composeState.value = composeState.value.copy(liveMessage = liveMessage.copy(typedMsg = typedMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -450,11 +624,18 @@ fun ComposeView(
|
|||||||
when (val preview = composeState.value.preview) {
|
when (val preview = composeState.value.preview) {
|
||||||
ComposePreview.NoPreview -> {}
|
ComposePreview.NoPreview -> {}
|
||||||
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
is ComposePreview.CLinkPreview -> ComposeLinkView(preview.linkPreview, ::cancelLinkPreview)
|
||||||
is ComposePreview.ImagePreview -> ComposeImageView(
|
is ComposePreview.MediaPreview -> ComposeImageView(
|
||||||
preview.images,
|
preview,
|
||||||
::cancelImages,
|
::cancelImages,
|
||||||
cancelEnabled = !composeState.value.editing
|
cancelEnabled = !composeState.value.editing
|
||||||
)
|
)
|
||||||
|
is ComposePreview.VoicePreview -> ComposeVoiceView(
|
||||||
|
preview.voice,
|
||||||
|
preview.durationMs,
|
||||||
|
preview.finished,
|
||||||
|
cancelEnabled = !composeState.value.editing,
|
||||||
|
::cancelVoice
|
||||||
|
)
|
||||||
is ComposePreview.FilePreview -> ComposeFileView(
|
is ComposePreview.FilePreview -> ComposeFileView(
|
||||||
preview.fileName,
|
preview.fileName,
|
||||||
::cancelFile,
|
::cancelFile,
|
||||||
@@ -467,60 +648,140 @@ fun ComposeView(
|
|||||||
fun contextItemView() {
|
fun contextItemView() {
|
||||||
when (val contextItem = composeState.value.contextItem) {
|
when (val contextItem = composeState.value.contextItem) {
|
||||||
ComposeContextItem.NoContextItem -> {}
|
ComposeContextItem.NoContextItem -> {}
|
||||||
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, Icons.Outlined.Reply) {
|
is ComposeContextItem.QuotedItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_reply)) {
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||||
}
|
}
|
||||||
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, Icons.Filled.Edit) {
|
is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(R.drawable.ic_edit_filled)) {
|
||||||
clearState()
|
clearState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(chatModel.sharedContent.value) {
|
LaunchedEffect(chatModel.sharedContent.value) {
|
||||||
|
// Important. If it's null, don't do anything, chat is not closed yet but will be after a moment
|
||||||
|
if (chatModel.chatId.value == null) return@LaunchedEffect
|
||||||
|
|
||||||
when (val shared = chatModel.sharedContent.value) {
|
when (val shared = chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> onMessageChange(shared.text)
|
is SharedContent.Text -> onMessageChange(shared.text)
|
||||||
is SharedContent.Images -> processPickedImage(shared.uris, shared.text)
|
is SharedContent.Media -> processPickedMedia(shared.uris, shared.text)
|
||||||
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
is SharedContent.File -> processPickedFile(shared.uri, shared.text)
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
chatModel.sharedContent.value = null
|
chatModel.sharedContent.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val userCanSend = rememberUpdatedState(chat.userCanSend)
|
||||||
|
val userIsObserver = rememberUpdatedState(chat.userIsObserver)
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
contextItemView()
|
contextItemView()
|
||||||
when {
|
when {
|
||||||
|
composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {}
|
||||||
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
composeState.value.editing && composeState.value.preview is ComposePreview.FilePreview -> {}
|
||||||
else -> previewView()
|
else -> previewView()
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(start = 4.dp, end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
|
||||||
) {
|
) {
|
||||||
val attachEnabled = !composeState.value.editing
|
IconButton(showChooseAttachment, enabled = !composeState.value.attachmentDisabled && rememberUpdatedState(chat.userCanSend).value) {
|
||||||
Box(Modifier.padding(bottom = 12.dp)) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.AttachFile,
|
painterResource(R.drawable.ic_attach_file_filled_500),
|
||||||
contentDescription = stringResource(R.string.attach),
|
contentDescription = stringResource(R.string.attach),
|
||||||
tint = if (attachEnabled) MaterialTheme.colors.primary else Color.Gray,
|
tint = if (!composeState.value.attachmentDisabled && userCanSend.value) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(28.dp)
|
.size(28.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.clickable {
|
|
||||||
if (attachEnabled) {
|
|
||||||
showChooseAttachment()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||||
|
LaunchedEffect(allowedVoiceByPrefs) {
|
||||||
|
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
// Voice was disabled right when this user records it, just cancel it
|
||||||
|
cancelVoice()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||||
|
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||||
|
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||||
|
contactPreference.allow == FeatureAllowed.YES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { recState.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
when(it) {
|
||||||
|
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||||
|
is RecordingState.Finished -> onAudioAdded(it.filePath, it.durationMs, true)
|
||||||
|
is RecordingState.NotStarted -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCurrentDraft() {
|
||||||
|
if (chatModel.draftChatId.value == chat.id) {
|
||||||
|
chatModel.draft.value = null
|
||||||
|
chatModel.draftChatId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(rememberUpdatedState(chat.userCanSend).value) {
|
||||||
|
if (!chat.userCanSend) {
|
||||||
|
clearCurrentDraft()
|
||||||
|
clearState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activity = LocalContext.current as Activity
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
if (orientation == activity.resources.configuration.orientation) {
|
||||||
|
val cs = composeState.value
|
||||||
|
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||||
|
sendMessage()
|
||||||
|
resetLinkPreview()
|
||||||
|
clearCurrentDraft()
|
||||||
|
deleteUnusedFiles()
|
||||||
|
} else if (!composeState.value.empty) {
|
||||||
|
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
||||||
|
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
||||||
|
}
|
||||||
|
chatModel.draft.value = composeState.value
|
||||||
|
chatModel.draftChatId.value = chat.id
|
||||||
|
} else {
|
||||||
|
clearCurrentDraft()
|
||||||
|
deleteUnusedFiles()
|
||||||
|
}
|
||||||
|
chatModel.removeLiveDummy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState,
|
composeState,
|
||||||
|
showVoiceRecordIcon = true,
|
||||||
|
recState,
|
||||||
|
chat.chatInfo is ChatInfo.Direct,
|
||||||
|
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||||
|
needToAllowVoiceToContact,
|
||||||
|
allowedVoiceByPrefs,
|
||||||
|
allowVoiceToContact = ::allowVoiceToContact,
|
||||||
|
userIsObserver = userIsObserver.value,
|
||||||
|
userCanSend = userCanSend.value,
|
||||||
sendMessage = {
|
sendMessage = {
|
||||||
sendMessage()
|
sendMessage()
|
||||||
resetLinkPreview()
|
resetLinkPreview()
|
||||||
},
|
},
|
||||||
::onMessageChange,
|
sendLiveMessage = ::sendLiveMessage,
|
||||||
textStyle
|
updateLiveMessage = ::updateLiveMessage,
|
||||||
|
cancelLiveMessage = {
|
||||||
|
composeState.value = composeState.value.copy(liveMessage = null)
|
||||||
|
chatModel.removeLiveDummy()
|
||||||
|
},
|
||||||
|
onMessageChange = ::onMessageChange,
|
||||||
|
textStyle = textStyle
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,7 +796,7 @@ class PickFromGallery: ActivityResultContract<Int, Uri?>() {
|
|||||||
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data
|
||||||
}
|
}
|
||||||
|
|
||||||
class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
class PickMultipleImagesFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
override fun createIntent(context: Context, input: Int) =
|
override fun createIntent(context: Context, input: Int) =
|
||||||
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
@@ -560,3 +821,30 @@ class PickMultipleFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
|||||||
else
|
else
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PickMultipleVideosFromGallery: ActivityResultContract<Int, List<Uri>>() {
|
||||||
|
override fun createIntent(context: Context, input: Int) =
|
||||||
|
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply {
|
||||||
|
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
type = "video/*"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> =
|
||||||
|
if (intent?.data != null)
|
||||||
|
listOf(intent.data!!)
|
||||||
|
else if (intent?.clipData != null)
|
||||||
|
with(intent.clipData!!) {
|
||||||
|
val uris = ArrayList<Uri>()
|
||||||
|
for (i in 0 until kotlin.math.min(itemCount, 10)) {
|
||||||
|
val uri = getItemAt(i).uri
|
||||||
|
if (uri != null) uris.add(uri)
|
||||||
|
}
|
||||||
|
if (itemCount > 10) {
|
||||||
|
AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc)
|
||||||
|
}
|
||||||
|
uris
|
||||||
|
}
|
||||||
|
else
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.durationText
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ComposeVoiceView(
|
||||||
|
filePath: String,
|
||||||
|
recordedDurationMs: Int,
|
||||||
|
finishedRecording: Boolean,
|
||||||
|
cancelEnabled: Boolean,
|
||||||
|
cancelVoice: () -> Unit
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
val audioPlaying = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val progress = rememberSaveable { mutableStateOf(0) }
|
||||||
|
val duration = rememberSaveable(recordedDurationMs) { mutableStateOf(recordedDurationMs) }
|
||||||
|
val progressBarWidth = remember { Animatable(0f) }
|
||||||
|
LaunchedEffect(recordedDurationMs, finishedRecording) {
|
||||||
|
snapshotFlow { progress.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
val startTime = when {
|
||||||
|
finishedRecording -> progress.value
|
||||||
|
else -> recordedDurationMs
|
||||||
|
}
|
||||||
|
val endTime = when {
|
||||||
|
finishedRecording -> duration.value
|
||||||
|
audioPlaying.value -> recordedDurationMs
|
||||||
|
else -> MAX_VOICE_MILLIS_FOR_SENDING
|
||||||
|
}
|
||||||
|
val to = ((startTime.toDouble() / endTime) * maxWidth.value).dp
|
||||||
|
progressBarWidth.animateTo(to.value, audioProgressBarAnimationSpec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(
|
||||||
|
Modifier
|
||||||
|
.requiredWidth(progressBarWidth.value.dp)
|
||||||
|
.padding(top = 58.dp)
|
||||||
|
.height(3.dp)
|
||||||
|
.background(MaterialTheme.colors.primary)
|
||||||
|
)
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.background(sentColor),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
if (!audioPlaying.value) {
|
||||||
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, false)
|
||||||
|
} else {
|
||||||
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = finishedRecording) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying.value) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
stringResource(R.string.icon_descr_file),
|
||||||
|
Modifier
|
||||||
|
.padding(start = 4.dp, end = 2.dp)
|
||||||
|
.size(36.dp),
|
||||||
|
tint = if (finishedRecording) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val numberInText = remember(recordedDurationMs, progress.value) {
|
||||||
|
derivedStateOf {
|
||||||
|
when {
|
||||||
|
finishedRecording && progress.value == 0 && !audioPlaying.value -> duration.value / 1000
|
||||||
|
finishedRecording -> progress.value / 1000
|
||||||
|
else -> recordedDurationMs / 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
durationText(numberInText.value),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
if (cancelEnabled) {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
AudioPlayer.stop(filePath)
|
||||||
|
cancelVoice()
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(0.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_cancel_file_preview),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier.padding(10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun PreviewComposeAudioView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
ComposeFileView(
|
||||||
|
"test.txt",
|
||||||
|
cancelFile = {},
|
||||||
|
cancelEnabled = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
|
import SectionItemView
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.PreferenceToggle
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactPreferencesView(
|
||||||
|
m: ChatModel,
|
||||||
|
user: User,
|
||||||
|
contactId: Long,
|
||||||
|
close: () -> Unit,
|
||||||
|
) {
|
||||||
|
val contact = remember { derivedStateOf { (m.getContactChat(contactId)?.chatInfo as? ChatInfo.Direct)?.contact } }
|
||||||
|
val ct = contact.value ?: return
|
||||||
|
var featuresAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(contactUserPrefsToFeaturesAllowed(ct.mergedPreferences)) }
|
||||||
|
var currentFeaturesAllowed by rememberSaveable(ct, stateSaver = serializableSaver()) { mutableStateOf(featuresAllowed) }
|
||||||
|
|
||||||
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
val prefs = contactFeaturesAllowedToPrefs(featuresAllowed)
|
||||||
|
val toContact = m.controller.apiSetContactPrefs(ct.contactId, prefs)
|
||||||
|
if (toContact != null) {
|
||||||
|
m.updateContact(toContact)
|
||||||
|
currentFeaturesAllowed = featuresAllowed
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (featuresAllowed == currentFeaturesAllowed) close()
|
||||||
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
ContactPreferencesLayout(
|
||||||
|
featuresAllowed,
|
||||||
|
currentFeaturesAllowed,
|
||||||
|
user,
|
||||||
|
ct,
|
||||||
|
applyPrefs = { prefs ->
|
||||||
|
featuresAllowed = prefs
|
||||||
|
},
|
||||||
|
reset = {
|
||||||
|
featuresAllowed = currentFeaturesAllowed
|
||||||
|
},
|
||||||
|
savePrefs = ::savePrefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactPreferencesLayout(
|
||||||
|
featuresAllowed: ContactFeaturesAllowed,
|
||||||
|
currentFeaturesAllowed: ContactFeaturesAllowed,
|
||||||
|
user: User,
|
||||||
|
contact: Contact,
|
||||||
|
applyPrefs: (ContactFeaturesAllowed) -> Unit,
|
||||||
|
reset: () -> Unit,
|
||||||
|
savePrefs: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.contact_preferences))
|
||||||
|
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
|
||||||
|
val onTTLUpdated = { ttl: Int? ->
|
||||||
|
applyPrefs(featuresAllowed.copy(timedMessagesTTL = ttl))
|
||||||
|
}
|
||||||
|
TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl ->
|
||||||
|
applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowFullDeletion: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) }
|
||||||
|
FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) {
|
||||||
|
applyPrefs(featuresAllowed.copy(fullDelete = it))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowVoice: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) }
|
||||||
|
FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) {
|
||||||
|
applyPrefs(featuresAllowed.copy(voice = it))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowCalls: MutableState<ContactFeatureAllowed> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) }
|
||||||
|
FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) {
|
||||||
|
applyPrefs(featuresAllowed.copy(calls = it))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
|
ResetSaveButtons(
|
||||||
|
reset = reset,
|
||||||
|
save = savePrefs,
|
||||||
|
disabled = featuresAllowed == currentFeaturesAllowed
|
||||||
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureSection(
|
||||||
|
feature: ChatFeature,
|
||||||
|
userDefault: FeatureAllowed,
|
||||||
|
pref: ContactUserPreference,
|
||||||
|
allowFeature: State<ContactFeatureAllowed>,
|
||||||
|
onSelected: (ContactFeatureAllowed) -> Unit
|
||||||
|
) {
|
||||||
|
val enabled = FeatureEnabled.enabled(
|
||||||
|
feature.asymmetric,
|
||||||
|
user = SimpleChatPreference(allow = allowFeature.value.allowed),
|
||||||
|
contact = pref.contactPreference
|
||||||
|
)
|
||||||
|
|
||||||
|
SectionView(
|
||||||
|
feature.text.uppercase(),
|
||||||
|
icon = feature.iconFilled(),
|
||||||
|
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||||
|
leadingIcon = true,
|
||||||
|
) {
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
|
ContactFeatureAllowed.values(userDefault).map { it to it.text },
|
||||||
|
allowFeature,
|
||||||
|
icon = null,
|
||||||
|
enabled = remember { mutableStateOf(feature != ChatFeature.Calls) },
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
InfoRow(
|
||||||
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
|
pref.contactPreference.allow.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimedMessagesFeatureSection(
|
||||||
|
featuresAllowed: ContactFeaturesAllowed,
|
||||||
|
pref: ContactUserPreferenceTimed,
|
||||||
|
allowFeature: State<Boolean>,
|
||||||
|
onTTLUpdated: (Int?) -> Unit,
|
||||||
|
onSelected: (Boolean, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
val enabled = FeatureEnabled.enabled(
|
||||||
|
ChatFeature.TimedMessages.asymmetric,
|
||||||
|
user = TimedMessagesPreference(allow = if (allowFeature.value) FeatureAllowed.YES else FeatureAllowed.NO),
|
||||||
|
contact = pref.contactPreference
|
||||||
|
)
|
||||||
|
|
||||||
|
SectionView(
|
||||||
|
ChatFeature.TimedMessages.text.uppercase(),
|
||||||
|
icon = ChatFeature.TimedMessages.iconFilled(),
|
||||||
|
iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red,
|
||||||
|
leadingIcon = true,
|
||||||
|
) {
|
||||||
|
PreferenceToggle(
|
||||||
|
generalGetString(R.string.chat_preferences_you_allow),
|
||||||
|
checked = allowFeature.value,
|
||||||
|
) { allow ->
|
||||||
|
onSelected(allow, if (allow) featuresAllowed.timedMessagesTTL ?: 86400 else null)
|
||||||
|
}
|
||||||
|
InfoRow(
|
||||||
|
generalGetString(R.string.chat_preferences_contact_allows),
|
||||||
|
pref.contactPreference.allow.text
|
||||||
|
)
|
||||||
|
if (featuresAllowed.timedMessagesAllowed) {
|
||||||
|
val ttl = rememberSaveable(featuresAllowed.timedMessagesTTL) { mutableStateOf(featuresAllowed.timedMessagesTTL) }
|
||||||
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
|
} else if (pref.contactPreference.allow == FeatureAllowed.YES || pref.contactPreference.allow == FeatureAllowed.ALWAYS) {
|
||||||
|
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(pref.contactPreference.ttl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionTextFooter(ChatFeature.TimedMessages.enabledDescription(enabled))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(reset, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_notify_contact), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TimedMessagesTTLPicker(selection: MutableState<Int?>, onSelected: (Int?) -> Unit) {
|
||||||
|
val ttlValues = TimedMessagesPreference.ttlValues
|
||||||
|
val values = ttlValues + if (ttlValues.contains(selection.value)) listOf() else listOf(selection.value)
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.delete_after),
|
||||||
|
values.map { it to TimedMessagesPreference.ttlText(it) },
|
||||||
|
selection,
|
||||||
|
onSelected = onSelected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_contact),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,35 +3,34 @@ package chat.simplex.app.views.chat
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.Close
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.CIDirection
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
import chat.simplex.app.views.chat.item.*
|
import chat.simplex.app.views.chat.item.*
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ContextItemView(
|
fun ContextItemView(
|
||||||
contextItem: ChatItem,
|
contextItem: ChatItem,
|
||||||
contextIcon: ImageVector,
|
contextIcon: Painter,
|
||||||
cancelContextItem: () -> Unit
|
cancelContextItem: () -> Unit
|
||||||
) {
|
) {
|
||||||
val sent = contextItem.chatDir.sent
|
val sent = contextItem.chatDir.sent
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.background(if (sent) SentColorLight else ReceivedColorLight),
|
.background(if (sent) sentColor else receivedColor),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -48,17 +47,18 @@ fun ContextItemView(
|
|||||||
.height(20.dp)
|
.height(20.dp)
|
||||||
.width(20.dp),
|
.width(20.dp),
|
||||||
contentDescription = stringResource(R.string.icon_descr_context),
|
contentDescription = stringResource(R.string.icon_descr_context),
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
)
|
)
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
contextItem.text, contextItem.formattedText,
|
contextItem.text, contextItem.formattedText,
|
||||||
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
sender = contextItem.memberDisplayName, senderBold = true, maxLines = 3,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = cancelContextItem) {
|
IconButton(onClick = cancelContextItem) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
contentDescription = stringResource(R.string.cancel_verb),
|
contentDescription = stringResource(R.string.cancel_verb),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
modifier = Modifier.padding(10.dp)
|
modifier = Modifier.padding(10.dp)
|
||||||
@@ -73,8 +73,7 @@ fun PreviewContextItemView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ContextItemView(
|
ContextItemView(
|
||||||
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
contextItem = ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), "hello"),
|
||||||
contextIcon = Icons.Filled.Edit,
|
contextIcon = painterResource(R.drawable.ic_edit_filled)
|
||||||
cancelContextItem = {}
|
) {}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.QRCodeScanner
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||||
|
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
cameraPermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
ScanCodeLayout(verifyCode, close)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScanCodeLayout(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.scan_code), false)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(ratio = 1F)
|
||||||
|
.padding(bottom = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
QRCodeScanner { text ->
|
||||||
|
verifyCode(text) {
|
||||||
|
if (it) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.incorrect_code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.scan_code_from_contacts_app))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,157 +1,582 @@
|
|||||||
package chat.simplex.app.views.chat
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.pm.ActivityInfo
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
|
import android.os.Build
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
import android.view.inputmethod.*
|
import android.view.inputmethod.*
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.ripple.rememberRipple
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowUpward
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.core.graphics.drawable.DrawableCompat
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
import androidx.core.view.inputmethod.EditorInfoCompat
|
import androidx.core.view.inputmethod.EditorInfoCompat
|
||||||
import androidx.core.view.inputmethod.InputConnectionCompat
|
import androidx.core.view.inputmethod.InputConnectionCompat
|
||||||
import androidx.core.widget.doOnTextChanged
|
import androidx.core.widget.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.SharedContent
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
import kotlinx.coroutines.delay
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.lang.reflect.Field
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SendMsgView(
|
fun SendMsgView(
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
|
showVoiceRecordIcon: Boolean,
|
||||||
|
recState: MutableState<RecordingState>,
|
||||||
|
isDirectChat: Boolean,
|
||||||
|
liveMessageAlertShown: SharedPreference<Boolean>,
|
||||||
|
needToAllowVoiceToContact: Boolean,
|
||||||
|
allowedVoiceByPrefs: Boolean,
|
||||||
|
userIsObserver: Boolean,
|
||||||
|
userCanSend: Boolean,
|
||||||
|
allowVoiceToContact: () -> Unit,
|
||||||
sendMessage: () -> Unit,
|
sendMessage: () -> Unit,
|
||||||
|
sendLiveMessage: (suspend () -> Unit)? = null,
|
||||||
|
updateLiveMessage: (suspend () -> Unit)? = null,
|
||||||
|
cancelLiveMessage: (() -> Unit)? = null,
|
||||||
onMessageChange: (String) -> Unit,
|
onMessageChange: (String) -> Unit,
|
||||||
textStyle: MutableState<TextStyle>
|
textStyle: MutableState<TextStyle>
|
||||||
) {
|
) {
|
||||||
val cs = composeState.value
|
Box(Modifier.padding(vertical = 8.dp)) {
|
||||||
var showKeyboard by remember { mutableStateOf(false) }
|
val cs = composeState.value
|
||||||
LaunchedEffect(cs.contextItem) {
|
val showProgress = cs.inProgress && (cs.preview is ComposePreview.MediaPreview || cs.preview is ComposePreview.FilePreview)
|
||||||
when (cs.contextItem) {
|
val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||||
is ComposeContextItem.QuotedItem -> {
|
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||||
delay(100)
|
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||||
showKeyboard = true
|
NativeKeyboard(composeState, textStyle, showDeleteTextButton, userIsObserver, onMessageChange)
|
||||||
|
// Disable clicks on text field
|
||||||
|
if (cs.preview is ComposePreview.VoicePreview || !userCanSend) {
|
||||||
|
Box(Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.clickable(enabled = !userCanSend, indication = null, interactionSource = remember { MutableInteractionSource() }, onClick = {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.observer_cant_send_message_title),
|
||||||
|
text = generalGetString(R.string.observer_cant_send_message_desc)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDeleteTextButton.value) {
|
||||||
|
DeleteTextButton(composeState)
|
||||||
|
}
|
||||||
|
Box(Modifier.align(Alignment.BottomEnd)) {
|
||||||
|
val sendButtonSize = remember { Animatable(36f) }
|
||||||
|
val sendButtonAlpha = remember { Animatable(1f) }
|
||||||
|
val permissionsState = rememberMultiplePermissionsState(listOf(Manifest.permission.RECORD_AUDIO))
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
// Making LiveMessage alive when screen orientation was changed
|
||||||
|
if (cs.liveMessage != null && sendLiveMessage != null && updateLiveMessage != null) {
|
||||||
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is ComposeContextItem.EditingItem -> {
|
when {
|
||||||
// Keyboard will not show up if we try to show it too fast
|
showProgress -> ProgressIndicator()
|
||||||
delay(300)
|
showVoiceButton -> {
|
||||||
showKeyboard = true
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||||
|
when {
|
||||||
|
needToAllowVoiceToContact || !allowedVoiceByPrefs || !userCanSend -> {
|
||||||
|
DisallowedVoiceButton(userCanSend) {
|
||||||
|
if (needToAllowVoiceToContact) {
|
||||||
|
showNeedToAllowVoiceAlert(allowVoiceToContact)
|
||||||
|
} else if (!allowedVoiceByPrefs) {
|
||||||
|
showDisabledVoiceAlert(isDirectChat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
!permissionsState.allPermissionsGranted ->
|
||||||
|
VoiceButtonWithoutPermission { permissionsState.launchMultiplePermissionRequest() }
|
||||||
|
else ->
|
||||||
|
RecordVoiceView(recState, stopRecOnNextClick)
|
||||||
|
}
|
||||||
|
if (sendLiveMessage != null
|
||||||
|
&& updateLiveMessage != null
|
||||||
|
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
|
||||||
|
&& cs.contextItem is ComposeContextItem.NoContextItem) {
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
StartLiveMessageButton(userCanSend) {
|
||||||
|
if (composeState.value.preview is ComposePreview.NoPreview) {
|
||||||
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cs.liveMessage?.sent == false && cs.message.isEmpty() -> {
|
||||||
|
CancelLiveMessageButton {
|
||||||
|
cancelLiveMessage?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val cs = composeState.value
|
||||||
|
val icon = if (cs.editing || cs.liveMessage != null) painterResource(R.drawable.ic_check_filled) else painterResource(R.drawable.ic_arrow_upward)
|
||||||
|
val disabled = !cs.sendEnabled() ||
|
||||||
|
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||||
|
cs.endLiveDisabled
|
||||||
|
if (cs.liveMessage == null &&
|
||||||
|
cs.preview !is ComposePreview.VoicePreview && !cs.editing &&
|
||||||
|
cs.contextItem is ComposeContextItem.NoContextItem &&
|
||||||
|
sendLiveMessage != null && updateLiveMessage != null
|
||||||
|
) {
|
||||||
|
val showDropdown = rememberSaveable { mutableStateOf(false) }
|
||||||
|
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage) { showDropdown.value = true }
|
||||||
|
|
||||||
|
DefaultDropdownMenu(
|
||||||
|
showDropdown,
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
generalGetString(R.string.send_live_message),
|
||||||
|
BoltFilled,
|
||||||
|
onClick = {
|
||||||
|
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
|
||||||
|
showDropdown.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SendMsgButton(icon, sendButtonSize, sendButtonAlpha, !disabled, sendMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NativeKeyboard(
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
|
textStyle: MutableState<TextStyle>,
|
||||||
|
showDeleteTextButton: MutableState<Boolean>,
|
||||||
|
userIsObserver: Boolean,
|
||||||
|
onMessageChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val cs = composeState.value
|
||||||
val textColor = MaterialTheme.colors.onBackground
|
val textColor = MaterialTheme.colors.onBackground
|
||||||
val tintColor = MaterialTheme.colors.secondary
|
val tintColor = MaterialTheme.colors.secondaryVariant
|
||||||
|
val padding = PaddingValues(12.dp, 7.dp, 45.dp, 0.dp)
|
||||||
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
val paddingStart = with(LocalDensity.current) { 12.dp.roundToPx() }
|
||||||
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
val paddingTop = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
val paddingEnd = with(LocalDensity.current) { 45.dp.roundToPx() }
|
||||||
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
val paddingBottom = with(LocalDensity.current) { 7.dp.roundToPx() }
|
||||||
|
var showKeyboard by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(cs.contextItem) {
|
||||||
|
if (cs.contextItem is ComposeContextItem.QuotedItem) {
|
||||||
|
delay(100)
|
||||||
|
showKeyboard = true
|
||||||
|
} else if (cs.contextItem is ComposeContextItem.EditingItem) {
|
||||||
|
// Keyboard will not show up if we try to show it too fast
|
||||||
|
delay(300)
|
||||||
|
showKeyboard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.padding(vertical = 8.dp)) {
|
AndroidView(modifier = Modifier, factory = {
|
||||||
Box {
|
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
||||||
AndroidView(modifier = Modifier, factory = {
|
override fun setOnReceiveContentListener(
|
||||||
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
|
mimeTypes: Array<out String>?,
|
||||||
override fun setOnReceiveContentListener(
|
listener: android.view.OnReceiveContentListener?
|
||||||
mimeTypes: Array<out String>?,
|
) {
|
||||||
listener: android.view.OnReceiveContentListener?
|
super.setOnReceiveContentListener(mimeTypes, listener)
|
||||||
) {
|
|
||||||
super.setOnReceiveContentListener(mimeTypes, listener)
|
|
||||||
}
|
|
||||||
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
|
||||||
val connection = super.onCreateInputConnection(editorInfo)
|
|
||||||
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
|
||||||
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
|
||||||
try {
|
|
||||||
inputContentInfo.requestPermission()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return@OnCommitContentListener false
|
|
||||||
}
|
|
||||||
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Images("", listOf(inputContentInfo.contentUri))
|
|
||||||
true
|
|
||||||
}
|
|
||||||
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
|
||||||
editText.maxLines = 16
|
|
||||||
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
|
||||||
editText.setTextColor(textColor.toArgb())
|
|
||||||
editText.textSize = textStyle.value.fontSize.value
|
|
||||||
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
|
||||||
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
|
||||||
editText.background = drawable
|
|
||||||
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
|
||||||
editText.setText(cs.message)
|
|
||||||
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, HighOrLowlight.toArgb()) }
|
|
||||||
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
|
||||||
editText
|
|
||||||
}) {
|
|
||||||
it.setTextColor(textColor.toArgb())
|
|
||||||
it.textSize = textStyle.value.fontSize.value
|
|
||||||
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
|
||||||
if (cs.message != it.text.toString()) {
|
|
||||||
it.setText(cs.message)
|
|
||||||
// Set cursor to the end of the text
|
|
||||||
it.setSelection(it.text.length)
|
|
||||||
}
|
|
||||||
if (showKeyboard) {
|
|
||||||
it.requestFocus()
|
|
||||||
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
|
||||||
showKeyboard = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Box(Modifier.align(Alignment.BottomEnd)) {
|
|
||||||
val icon = if (cs.editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward
|
override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection {
|
||||||
val color = if (cs.sendEnabled()) MaterialTheme.colors.primary else HighOrLowlight
|
val connection = super.onCreateInputConnection(editorInfo)
|
||||||
if (cs.inProgress
|
EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*"))
|
||||||
&& (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview)
|
val onCommit = InputConnectionCompat.OnCommitContentListener { inputContentInfo, _, _ ->
|
||||||
) {
|
try {
|
||||||
CircularProgressIndicator(
|
inputContentInfo.requestPermission()
|
||||||
Modifier
|
} catch (e: Exception) {
|
||||||
.size(36.dp)
|
return@OnCommitContentListener false
|
||||||
.padding(4.dp),
|
}
|
||||||
color = HighOrLowlight,
|
SimplexApp.context.chatModel.sharedContent.value = SharedContent.Media("", listOf(inputContentInfo.contentUri))
|
||||||
strokeWidth = 3.dp
|
true
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
stringResource(R.string.icon_descr_send_message),
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.padding(4.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(color)
|
|
||||||
.clickable {
|
|
||||||
if (cs.sendEnabled()) {
|
|
||||||
sendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return InputConnectionCompat.createWrapper(connection, editorInfo, onCommit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editText.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
editText.maxLines = 16
|
||||||
|
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
|
||||||
|
editText.setTextColor(textColor.toArgb())
|
||||||
|
editText.textSize = textStyle.value.fontSize.value
|
||||||
|
val drawable = it.getDrawable(R.drawable.send_msg_view_background)!!
|
||||||
|
DrawableCompat.setTint(drawable, tintColor.toArgb())
|
||||||
|
editText.background = drawable
|
||||||
|
editText.setPadding(paddingStart, paddingTop, paddingEnd, paddingBottom)
|
||||||
|
editText.setText(cs.message)
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
editText.textCursorDrawable?.let { DrawableCompat.setTint(it, CurrentColors.value.colors.secondary.toArgb()) }
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val f: Field = TextView::class.java.getDeclaredField("mCursorDrawableRes")
|
||||||
|
f.isAccessible = true
|
||||||
|
f.set(editText, R.drawable.edit_text_cursor)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(chat.simplex.app.TAG, e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editText.doOnTextChanged { text, _, _, _ -> onMessageChange(text.toString()) }
|
||||||
|
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
|
||||||
|
editText
|
||||||
|
}) {
|
||||||
|
it.setTextColor(textColor.toArgb())
|
||||||
|
it.textSize = textStyle.value.fontSize.value
|
||||||
|
DrawableCompat.setTint(it.background, tintColor.toArgb())
|
||||||
|
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
|
||||||
|
it.isFocusableInTouchMode = it.isFocusable
|
||||||
|
if (cs.message != it.text.toString()) {
|
||||||
|
it.setText(cs.message)
|
||||||
|
// Set cursor to the end of the text
|
||||||
|
it.setSelection(it.text.length)
|
||||||
|
}
|
||||||
|
if (showKeyboard) {
|
||||||
|
it.requestFocus()
|
||||||
|
val imm: InputMethodManager = SimplexApp.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
imm.showSoftInput(it, InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
showKeyboard = false
|
||||||
|
}
|
||||||
|
showDeleteTextButton.value = it.lineCount >= 4
|
||||||
|
}
|
||||||
|
if (composeState.value.preview is ComposePreview.VoicePreview) {
|
||||||
|
ComposeOverlay(R.string.voice_message_send_text, textStyle, padding)
|
||||||
|
} else if (userIsObserver) {
|
||||||
|
ComposeOverlay(R.string.you_are_observer, textStyle, padding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ComposeOverlay(textId: Int, textStyle: MutableState<TextStyle>, padding: PaddingValues) {
|
||||||
|
Text(
|
||||||
|
generalGetString(textId),
|
||||||
|
Modifier.padding(padding),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
style = textStyle.value.copy(fontStyle = FontStyle.Italic)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.DeleteTextButton(composeState: MutableState<ComposeState>) {
|
||||||
|
IconButton(
|
||||||
|
{ composeState.value = composeState.value.copy(message = "") },
|
||||||
|
Modifier.align(Alignment.TopEnd).size(36.dp)
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_close), null, Modifier.padding(7.dp).size(36.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordVoiceView(recState: MutableState<RecordingState>, stopRecOnNextClick: MutableState<Boolean>) {
|
||||||
|
val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) }
|
||||||
|
DisposableEffect(Unit) { onDispose { rec.stop() } }
|
||||||
|
val stopRecordingAndAddAudio: () -> Unit = {
|
||||||
|
recState.value.filePathNullable?.let {
|
||||||
|
recState.value = RecordingState.Finished(it, rec.stop())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (stopRecOnNextClick.value) {
|
||||||
|
LaunchedEffect(recState.value) {
|
||||||
|
if (recState.value is RecordingState.NotStarted) {
|
||||||
|
stopRecOnNextClick.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lock orientation to current orientation because screen rotation will break the recording
|
||||||
|
LockToCurrentOrientationUntilDispose()
|
||||||
|
StopRecordButton(stopRecordingAndAddAudio)
|
||||||
|
} else {
|
||||||
|
val startRecording: () -> Unit = {
|
||||||
|
recState.value = RecordingState.Started(
|
||||||
|
filePath = rec.start { progress: Int?, finished: Boolean ->
|
||||||
|
val state = recState.value
|
||||||
|
if (state is RecordingState.Started && progress != null) {
|
||||||
|
recState.value = if (!finished)
|
||||||
|
RecordingState.Started(state.filePath, progress)
|
||||||
|
else
|
||||||
|
RecordingState.Finished(state.filePath, progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val interactionSource = interactionSourceWithTapDetection(
|
||||||
|
onPress = { if (recState.value is RecordingState.NotStarted) startRecording() },
|
||||||
|
onClick = {
|
||||||
|
if (stopRecOnNextClick.value) {
|
||||||
|
stopRecordingAndAddAudio()
|
||||||
|
} else {
|
||||||
|
// tapped and didn't hold a finger
|
||||||
|
stopRecOnNextClick.value = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCancel = stopRecordingAndAddAudio,
|
||||||
|
onRelease = stopRecordingAndAddAudio
|
||||||
|
)
|
||||||
|
RecordVoiceButton(interactionSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DisallowedVoiceButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
IconButton(onClick, Modifier.size(36.dp), enabled = enabled) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_keyboard_voice),
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = MaterialTheme.colors.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceButtonWithoutPermission(onClick: () -> Unit) {
|
||||||
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LockToCurrentOrientationUntilDispose() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val activity = context as Activity
|
||||||
|
val manager = context.getSystemService(Activity.WINDOW_SERVICE) as WindowManager
|
||||||
|
val rotation = if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) manager.defaultDisplay.rotation else activity.display?.rotation
|
||||||
|
activity.requestedOrientation = when (rotation) {
|
||||||
|
android.view.Surface.ROTATION_90 -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
|
||||||
|
android.view.Surface.ROTATION_180 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT
|
||||||
|
android.view.Surface.ROTATION_270 -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
|
||||||
|
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||||
|
}
|
||||||
|
// Unlock orientation
|
||||||
|
onDispose { activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StopRecordButton(onClick: () -> Unit) {
|
||||||
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_stop_filled),
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordVoiceButton(interactionSource: MutableInteractionSource) {
|
||||||
|
IconButton({}, Modifier.size(36.dp), interactionSource = interactionSource) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_keyboard_voice_filled),
|
||||||
|
stringResource(R.string.icon_descr_record_voice_message),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressIndicator() {
|
||||||
|
CircularProgressIndicator(Modifier.size(36.dp).padding(4.dp), color = MaterialTheme.colors.secondary, strokeWidth = 3.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelLiveMessageButton(
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
IconButton(onClick, Modifier.size(36.dp)) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
stringResource(R.string.icon_descr_cancel_live_message),
|
||||||
|
tint = MaterialTheme.colors.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SendMsgButton(
|
||||||
|
icon: Painter,
|
||||||
|
sizeDp: Animatable<Float, AnimationVector1D>,
|
||||||
|
alpha: Animatable<Float, AnimationVector1D>,
|
||||||
|
enabled: Boolean,
|
||||||
|
sendMessage: () -> Unit,
|
||||||
|
onLongClick: (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.requiredSize(36.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = sendMessage,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
enabled = enabled,
|
||||||
|
role = Role.Button,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(R.string.icon_descr_send_message),
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(sizeDp.value.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
.alpha(alpha.value)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary)
|
||||||
|
.padding(3.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.requiredSize(36.dp)
|
||||||
|
.clickable(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
role = Role.Button,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = rememberRipple(bounded = false, radius = 24.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
BoltFilled,
|
||||||
|
stringResource(R.string.icon_descr_send_message),
|
||||||
|
tint = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.padding(4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLiveMessage(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
send: suspend () -> Unit,
|
||||||
|
update: suspend () -> Unit,
|
||||||
|
sendButtonSize: Animatable<Float, AnimationVector1D>,
|
||||||
|
sendButtonAlpha: Animatable<Float, AnimationVector1D>,
|
||||||
|
composeState: MutableState<ComposeState>,
|
||||||
|
liveMessageAlertShown: SharedPreference<Boolean>
|
||||||
|
) {
|
||||||
|
fun run() {
|
||||||
|
scope.launch {
|
||||||
|
while (composeState.value.liveMessage != null) {
|
||||||
|
sendButtonSize.animateTo(if (sendButtonSize.value == 36f) 32f else 36f, tween(700, 50))
|
||||||
|
}
|
||||||
|
sendButtonSize.snapTo(36f)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
while (composeState.value.liveMessage != null) {
|
||||||
|
sendButtonAlpha.animateTo(if (sendButtonAlpha.value == 1f) 0.75f else 1f, tween(700, 50))
|
||||||
|
}
|
||||||
|
sendButtonAlpha.snapTo(1f)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
delay(3000)
|
||||||
|
while (composeState.value.liveMessage != null) {
|
||||||
|
update()
|
||||||
|
delay(3000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun start() = withBGApi {
|
||||||
|
if (composeState.value.liveMessage == null) {
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (liveMessageAlertShown.state.value) {
|
||||||
|
start()
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.live_message),
|
||||||
|
text = generalGetString(R.string.send_live_message_desc),
|
||||||
|
confirmText = generalGetString(R.string.send_verb),
|
||||||
|
onConfirm = {
|
||||||
|
liveMessageAlertShown.set(true)
|
||||||
|
start()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNeedToAllowVoiceAlert(onConfirm: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.allow_voice_messages_question),
|
||||||
|
text = generalGetString(R.string.you_need_to_allow_to_send_voice),
|
||||||
|
confirmText = generalGetString(R.string.allow_verb),
|
||||||
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
|
onConfirm = onConfirm,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDisabledVoiceAlert(isDirectChat: Boolean) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.voice_messages_prohibited),
|
||||||
|
text = generalGetString(
|
||||||
|
if (isDirectChat)
|
||||||
|
R.string.ask_your_contact_to_enable_voice
|
||||||
|
else
|
||||||
|
R.string.only_group_owners_can_enable_voice
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@@ -167,6 +592,15 @@ fun PreviewSendMsgView() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
|
isDirectChat = true,
|
||||||
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
@@ -188,6 +622,15 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateEditing) },
|
composeState = remember { mutableStateOf(composeStateEditing) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
|
isDirectChat = true,
|
||||||
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
@@ -205,10 +648,19 @@ fun PreviewSendMsgViewEditing() {
|
|||||||
fun PreviewSendMsgViewInProgress() {
|
fun PreviewSendMsgViewInProgress() {
|
||||||
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
|
||||||
val textStyle = remember { mutableStateOf(smallFont) }
|
val textStyle = remember { mutableStateOf(smallFont) }
|
||||||
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt"), inProgress = true, useLinkPreviews = true)
|
val composeStateInProgress = ComposeState(preview = ComposePreview.FilePreview("test.txt", getAppFileUri("test.txt")), inProgress = true, useLinkPreviews = true)
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
SendMsgView(
|
SendMsgView(
|
||||||
composeState = remember { mutableStateOf(composeStateInProgress) },
|
composeState = remember { mutableStateOf(composeStateInProgress) },
|
||||||
|
showVoiceRecordIcon = false,
|
||||||
|
recState = remember { mutableStateOf(RecordingState.NotStarted) },
|
||||||
|
isDirectChat = true,
|
||||||
|
liveMessageAlertShown = SharedPreference(get = { true }, set = { }),
|
||||||
|
needToAllowVoiceToContact = false,
|
||||||
|
allowedVoiceByPrefs = true,
|
||||||
|
userIsObserver = false,
|
||||||
|
userCanSend = true,
|
||||||
|
allowVoiceToContact = {},
|
||||||
sendMessage = {},
|
sendMessage = {},
|
||||||
onMessageChange = { _ -> },
|
onMessageChange = { _ -> },
|
||||||
textStyle = textStyle
|
textStyle = textStyle
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package chat.simplex.app.views.chat
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerifyCodeView(
|
||||||
|
displayName: String,
|
||||||
|
connectionCode: String?,
|
||||||
|
connectionVerified: Boolean,
|
||||||
|
verify: suspend (String?) -> Pair<Boolean, String>?,
|
||||||
|
close: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (connectionCode != null) {
|
||||||
|
VerifyCodeLayout(
|
||||||
|
displayName,
|
||||||
|
connectionCode,
|
||||||
|
connectionVerified,
|
||||||
|
verifyCode = { newCode, cb ->
|
||||||
|
withBGApi {
|
||||||
|
val res = verify(newCode)
|
||||||
|
if (res != null) {
|
||||||
|
val (verified) = res
|
||||||
|
cb(verified)
|
||||||
|
if (verified) close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VerifyCodeLayout(
|
||||||
|
displayName: String,
|
||||||
|
connectionCode: String,
|
||||||
|
connectionVerified: Boolean,
|
||||||
|
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.security_code), false)
|
||||||
|
val splitCode = splitToParts(connectionCode, 24)
|
||||||
|
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
|
||||||
|
if (connectionVerified) {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 4.dp).size(22.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
Text(String.format(stringResource(R.string.is_verified), displayName))
|
||||||
|
} else {
|
||||||
|
Text(String.format(stringResource(R.string.is_not_verified), displayName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionView {
|
||||||
|
QRCode(connectionCode, Modifier.aspectRatio(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.weight(2f))
|
||||||
|
SelectionContainer(Modifier.padding(vertical = DEFAULT_PADDING_HALF, horizontal = DEFAULT_PADDING_HALF)) {
|
||||||
|
Text(
|
||||||
|
splitCode,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
maxLines = 20
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
Box(Modifier.weight(1f)) {
|
||||||
|
IconButton({ shareText(context, connectionCode) }, Modifier.size(20.dp).align(Alignment.CenterStart)) {
|
||||||
|
Icon(painterResource(R.drawable.ic_share_filled), null, tint = MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
generalGetString(R.string.to_verify_compare),
|
||||||
|
Modifier.padding(bottom = DEFAULT_PADDING)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
Modifier.padding(bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (connectionVerified) {
|
||||||
|
SimpleButton(generalGetString(R.string.clear_verification), painterResource(R.drawable.ic_shield)) {
|
||||||
|
verifyCode(null) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SimpleButton(generalGetString(R.string.scan_code), painterResource(R.drawable.ic_qr_code)) {
|
||||||
|
ModalManager.shared.showModal {
|
||||||
|
ScanCodeView(verifyCode) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SimpleButton(generalGetString(R.string.mark_code_verified), painterResource(R.drawable.ic_verified_user)) {
|
||||||
|
verifyCode(connectionCode) { verified ->
|
||||||
|
if (!verified) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.incorrect_code)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitToParts(s: String, length: Int): String {
|
||||||
|
if (length >= s.length) return s
|
||||||
|
return (0..(s.length - 1) / length)
|
||||||
|
.map { s.drop(it * length).take(length) }
|
||||||
|
.joinToString(separator = "\n")
|
||||||
|
}
|
||||||
+70
-35
@@ -1,7 +1,8 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionCustomFooter
|
import SectionCustomFooter
|
||||||
import SectionDivider
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
@@ -9,15 +10,12 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.TheaterComedy
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -28,20 +26,28 @@ import chat.simplex.app.model.*
|
|||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
import chat.simplex.app.views.chat.ChatInfoToolbarTitle
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.newchat.InfoAboutIncognito
|
||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) {
|
fun AddGroupMembersView(groupInfo: GroupInfo, creatingGroup: Boolean = false, chatModel: ChatModel, close: () -> Unit) {
|
||||||
val selectedContacts = remember { mutableStateListOf<Long>() }
|
val selectedContacts = remember { mutableStateListOf<Long>() }
|
||||||
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
val selectedRole = remember { mutableStateOf(GroupMemberRole.Member) }
|
||||||
var allowModifyMembers by remember { mutableStateOf(true) }
|
var allowModifyMembers by remember { mutableStateOf(true) }
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModel.incognito.value,
|
||||||
groupInfo = groupInfo,
|
groupInfo = groupInfo,
|
||||||
|
creatingGroup = creatingGroup,
|
||||||
contactsToAdd = getContactsToAdd(chatModel),
|
contactsToAdd = getContactsToAdd(chatModel),
|
||||||
selectedContacts = selectedContacts,
|
selectedContacts = selectedContacts,
|
||||||
selectedRole = selectedRole,
|
selectedRole = selectedRole,
|
||||||
allowModifyMembers = allowModifyMembers,
|
allowModifyMembers = allowModifyMembers,
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
GroupPreferencesView(chatModel, groupInfo.id, close)
|
||||||
|
}
|
||||||
|
},
|
||||||
inviteMembers = {
|
inviteMembers = {
|
||||||
allowModifyMembers = false
|
allowModifyMembers = false
|
||||||
withApi {
|
withApi {
|
||||||
@@ -59,6 +65,7 @@ fun AddGroupMembersView(groupInfo: GroupInfo, chatModel: ChatModel, close: () ->
|
|||||||
clearSelection = { selectedContacts.clear() },
|
clearSelection = { selectedContacts.clear() },
|
||||||
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
addContact = { contactId -> if (contactId !in selectedContacts) selectedContacts.add(contactId) },
|
||||||
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
removeContact = { contactId -> selectedContacts.removeIf { it == contactId } },
|
||||||
|
close = close,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,23 +85,34 @@ fun getContactsToAdd(chatModel: ChatModel): List<Contact> {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddGroupMembersLayout(
|
fun AddGroupMembersLayout(
|
||||||
|
chatModelIncognito: Boolean,
|
||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
|
creatingGroup: Boolean,
|
||||||
contactsToAdd: List<Contact>,
|
contactsToAdd: List<Contact>,
|
||||||
selectedContacts: List<Long>,
|
selectedContacts: List<Long>,
|
||||||
selectedRole: MutableState<GroupMemberRole>,
|
selectedRole: MutableState<GroupMemberRole>,
|
||||||
allowModifyMembers: Boolean,
|
allowModifyMembers: Boolean,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
inviteMembers: () -> Unit,
|
inviteMembers: () -> Unit,
|
||||||
clearSelection: () -> Unit,
|
clearSelection: () -> Unit,
|
||||||
addContact: (Long) -> Unit,
|
addContact: (Long) -> Unit,
|
||||||
removeContact: (Long) -> Unit,
|
removeContact: (Long) -> Unit,
|
||||||
|
close: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.button_add_members))
|
AppBarTitle(stringResource(R.string.button_add_members))
|
||||||
|
InfoAboutIncognito(
|
||||||
|
chatModelIncognito,
|
||||||
|
false,
|
||||||
|
generalGetString(R.string.group_unsupported_incognito_main_profile_sent),
|
||||||
|
generalGetString(R.string.group_main_profile_sent),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
@@ -115,27 +133,33 @@ fun AddGroupMembersLayout(
|
|||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_contacts_to_add),
|
stringResource(R.string.no_contacts_to_add),
|
||||||
Modifier.padding(),
|
Modifier.padding(),
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SectionView {
|
SectionView {
|
||||||
SectionItemView {
|
if (creatingGroup) {
|
||||||
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
SectionItemView(openPreferences) {
|
||||||
|
Text(stringResource(R.string.set_group_preferences))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoleSelectionRow(groupInfo, selectedRole, allowModifyMembers)
|
||||||
|
if (creatingGroup && selectedContacts.isEmpty()) {
|
||||||
|
SkipInvitingButton(close)
|
||||||
|
} else {
|
||||||
|
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
InviteMembersButton(inviteMembers, disabled = selectedContacts.isEmpty() || !allowModifyMembers)
|
|
||||||
}
|
}
|
||||||
SectionCustomFooter {
|
SectionCustomFooter {
|
||||||
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView {
|
SectionView(stringResource(R.string.select_contacts)) {
|
||||||
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
ContactList(contacts = contactsToAdd, selectedContacts, groupInfo, allowModifyMembers, addContact, removeContact)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,16 +176,15 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||||||
values,
|
values,
|
||||||
selectedRole,
|
selectedRole,
|
||||||
icon = null,
|
icon = null,
|
||||||
enabled = rememberUpdatedState(enabled),
|
enabled = rememberUpdatedState(enabled)
|
||||||
onSelected = { selectedRole.value = it }
|
) { selectedRole.value = it }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Check,
|
painterResource(R.drawable.ic_check),
|
||||||
stringResource(R.string.invite_to_group_button),
|
stringResource(R.string.invite_to_group_button),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
@@ -170,6 +193,17 @@ fun InviteMembersButton(onClick: () -> Unit, disabled: Boolean) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SkipInvitingButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_check),
|
||||||
|
stringResource(R.string.skip_inviting_button),
|
||||||
|
click = onClick,
|
||||||
|
textColor = MaterialTheme.colors.primary,
|
||||||
|
iconColor = MaterialTheme.colors.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelection: () -> Unit) {
|
||||||
Row(
|
Row(
|
||||||
@@ -180,7 +214,7 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
if (selectedContactsCount >= 1) {
|
if (selectedContactsCount >= 1) {
|
||||||
Text(
|
Text(
|
||||||
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
String.format(generalGetString(R.string.num_contacts_selected), selectedContactsCount),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
@@ -188,14 +222,14 @@ fun InviteSectionFooter(selectedContactsCount: Int, enabled: Boolean, clearSelec
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.clear_contacts_selection_button),
|
stringResource(R.string.clear_contacts_selection_button),
|
||||||
color = if (enabled) MaterialTheme.colors.primary else HighOrLowlight,
|
color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.no_contacts_selected),
|
stringResource(R.string.no_contacts_selected),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp
|
fontSize = 12.sp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -218,9 +252,6 @@ fun ContactList(
|
|||||||
checked = selectedContacts.contains(contact.apiId),
|
checked = selectedContacts.contains(contact.apiId),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
)
|
)
|
||||||
if (index < contacts.lastIndex) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,17 +266,17 @@ fun ContactCheckRow(
|
|||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
) {
|
) {
|
||||||
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
val prohibitedToInviteIncognito = !groupInfo.membership.memberIncognito && contact.contactConnIncognito
|
||||||
val icon: ImageVector
|
val icon: Painter
|
||||||
val iconColor: Color
|
val iconColor: Color
|
||||||
if (prohibitedToInviteIncognito) {
|
if (prohibitedToInviteIncognito) {
|
||||||
icon = Icons.Filled.TheaterComedy
|
icon = painterResource(R.drawable.ic_theater_comedy_filled)
|
||||||
iconColor = HighOrLowlight
|
iconColor = MaterialTheme.colors.secondary
|
||||||
} else if (checked) {
|
} else if (checked) {
|
||||||
icon = Icons.Filled.CheckCircle
|
icon = painterResource(R.drawable.ic_check_circle_filled)
|
||||||
iconColor = if (enabled) MaterialTheme.colors.primary else HighOrLowlight
|
iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
} else {
|
} else {
|
||||||
icon = Icons.Outlined.Circle
|
icon = painterResource(R.drawable.ic_circle)
|
||||||
iconColor = HighOrLowlight
|
iconColor = MaterialTheme.colors.secondary
|
||||||
}
|
}
|
||||||
SectionItemView(
|
SectionItemView(
|
||||||
click = if (enabled) {
|
click = if (enabled) {
|
||||||
@@ -263,7 +294,7 @@ fun ContactCheckRow(
|
|||||||
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
|
||||||
Text(
|
Text(
|
||||||
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
contact.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
color = if (prohibitedToInviteIncognito) HighOrLowlight else Color.Unspecified
|
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
|
||||||
)
|
)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Icon(
|
Icon(
|
||||||
@@ -287,15 +318,19 @@ fun showProhibitedToInviteIncognitoAlertDialog() {
|
|||||||
fun PreviewAddGroupMembersLayout() {
|
fun PreviewAddGroupMembersLayout() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
AddGroupMembersLayout(
|
AddGroupMembersLayout(
|
||||||
|
chatModelIncognito = false,
|
||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
|
creatingGroup = false,
|
||||||
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
contactsToAdd = listOf(Contact.sampleData, Contact.sampleData, Contact.sampleData),
|
||||||
selectedContacts = remember { mutableStateListOf() },
|
selectedContacts = remember { mutableStateListOf() },
|
||||||
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
selectedRole = remember { mutableStateOf(GroupMemberRole.Admin) },
|
||||||
allowModifyMembers = true,
|
allowModifyMembers = true,
|
||||||
|
openPreferences = {},
|
||||||
inviteMembers = {},
|
inviteMembers = {},
|
||||||
clearSelection = {},
|
clearSelection = {},
|
||||||
addContact = {},
|
addContact = {},
|
||||||
removeContact = {}
|
removeContact = {},
|
||||||
|
close = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-121
@@ -1,20 +1,22 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
|
import android.util.Log
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -22,15 +24,17 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.*
|
import chat.simplex.app.views.chat.*
|
||||||
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
import chat.simplex.app.views.chatlist.cantInviteIncognitoAlert
|
||||||
import chat.simplex.app.views.chatlist.setGroupMembers
|
import chat.simplex.app.views.chatlist.setGroupMembers
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit, close: () -> Unit) {
|
||||||
BackHandler(onBack = close)
|
BackHandler(onBack = close)
|
||||||
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
|
||||||
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
val developerTools = chatModel.controller.appPrefs.developerTools.get()
|
||||||
@@ -43,33 +47,58 @@ fun GroupChatInfoView(chatModel: ChatModel, close: () -> Unit) {
|
|||||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||||
.sortedBy { it.displayName.lowercase() },
|
.sortedBy { it.displayName.lowercase() },
|
||||||
developerTools,
|
developerTools,
|
||||||
|
groupLink,
|
||||||
addMembers = {
|
addMembers = {
|
||||||
withApi {
|
withApi {
|
||||||
setGroupMembers(groupInfo, chatModel)
|
setGroupMembers(groupInfo, chatModel)
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
AddGroupMembersView(groupInfo, chatModel, close)
|
AddGroupMembersView(groupInfo, false, chatModel, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showMemberInfo = { member ->
|
showMemberInfo = { member ->
|
||||||
withApi {
|
withApi {
|
||||||
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
val stats = chatModel.controller.apiGroupMemberInfo(groupInfo.groupId, member.groupMemberId)
|
||||||
|
val (_, code) = if (member.memberActive) {
|
||||||
|
try {
|
||||||
|
chatModel.controller.apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, e.stackTraceToString())
|
||||||
|
member to null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
member to null
|
||||||
|
}
|
||||||
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
ModalManager.shared.showModalCloseable(true) { closeCurrent ->
|
||||||
GroupMemberInfoView(groupInfo, member, stats, chatModel, closeCurrent) { closeCurrent(); close() }
|
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||||
|
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
|
||||||
|
closeCurrent()
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editGroupProfile = {
|
editGroupProfile = {
|
||||||
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
ModalManager.shared.showCustomModal { close -> GroupProfileView(groupInfo, chatModel, close) }
|
||||||
},
|
},
|
||||||
|
addOrEditWelcomeMessage = {
|
||||||
|
ModalManager.shared.showCustomModal { close -> GroupWelcomeView(chatModel, groupInfo, close) }
|
||||||
|
},
|
||||||
|
openPreferences = {
|
||||||
|
ModalManager.shared.showCustomModal { close ->
|
||||||
|
GroupPreferencesView(
|
||||||
|
chatModel,
|
||||||
|
chat.id,
|
||||||
|
close
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
deleteGroup = { deleteGroupDialog(chat.chatInfo, groupInfo, chatModel, close) },
|
||||||
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
clearChat = { clearChatDialog(chat.chatInfo, chatModel, close) },
|
||||||
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
leaveGroup = { leaveGroupDialog(groupInfo, chatModel, close) },
|
||||||
manageGroupLink = {
|
manageGroupLink = {
|
||||||
withApi {
|
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink, groupLinkMemberRole, onGroupLinkUpdated) }
|
||||||
val groupLink = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
|
|
||||||
ModalManager.shared.showModal { GroupLinkView(chatModel, groupInfo, groupLink) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -79,7 +108,7 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
|
|||||||
val alertTextKey =
|
val alertTextKey =
|
||||||
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
if (groupInfo.membership.memberCurrent) R.string.delete_group_for_all_members_cannot_undo_warning
|
||||||
else R.string.delete_group_for_self_cannot_undo_warning
|
else R.string.delete_group_for_self_cannot_undo_warning
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_group_question),
|
title = generalGetString(R.string.delete_group_question),
|
||||||
text = generalGetString(alertTextKey),
|
text = generalGetString(alertTextKey),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -93,12 +122,13 @@ fun deleteGroupDialog(chatInfo: ChatInfo, groupInfo: GroupInfo, chatModel: ChatM
|
|||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.leave_group_question),
|
title = generalGetString(R.string.leave_group_question),
|
||||||
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
text = generalGetString(R.string.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved),
|
||||||
confirmText = generalGetString(R.string.leave_group_button),
|
confirmText = generalGetString(R.string.leave_group_button),
|
||||||
@@ -107,7 +137,8 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
|
|||||||
chatModel.controller.leaveGroup(groupInfo.groupId)
|
chatModel.controller.leaveGroup(groupInfo.groupId)
|
||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +148,12 @@ fun GroupChatInfoLayout(
|
|||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
members: List<GroupMember>,
|
members: List<GroupMember>,
|
||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
|
groupLink: String?,
|
||||||
addMembers: () -> Unit,
|
addMembers: () -> Unit,
|
||||||
showMemberInfo: (GroupMember) -> Unit,
|
showMemberInfo: (GroupMember) -> Unit,
|
||||||
editGroupProfile: () -> Unit,
|
editGroupProfile: () -> Unit,
|
||||||
|
addOrEditWelcomeMessage: () -> Unit,
|
||||||
|
openPreferences: () -> Unit,
|
||||||
deleteGroup: () -> Unit,
|
deleteGroup: () -> Unit,
|
||||||
clearChat: () -> Unit,
|
clearChat: () -> Unit,
|
||||||
leaveGroup: () -> Unit,
|
leaveGroup: () -> Unit,
|
||||||
@@ -128,8 +162,7 @@ fun GroupChatInfoLayout(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState())
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -139,56 +172,57 @@ fun GroupChatInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
|
SectionView {
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
EditGroupProfileButton(editGroupProfile)
|
||||||
|
AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage)
|
||||||
|
}
|
||||||
|
GroupPreferencesButton(openPreferences)
|
||||||
|
}
|
||||||
|
SectionTextFooter(stringResource(R.string.only_group_owners_can_change_prefs))
|
||||||
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
SectionView(title = String.format(generalGetString(R.string.group_info_section_title_num_members), members.count() + 1)) {
|
||||||
if (groupInfo.canAddMembers) {
|
if (groupInfo.canAddMembers) {
|
||||||
SectionItemView(manageGroupLink) { GroupLinkButton() }
|
if (groupLink == null) {
|
||||||
SectionDivider()
|
CreateGroupLinkButton(manageGroupLink)
|
||||||
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
} else {
|
||||||
SectionItemView(onAddMembersClick) {
|
GroupLinkButton(manageGroupLink)
|
||||||
val tint = if (chat.chatInfo.incognito) HighOrLowlight else MaterialTheme.colors.primary
|
|
||||||
AddMembersButton(tint)
|
|
||||||
}
|
}
|
||||||
SectionDivider()
|
|
||||||
|
val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers
|
||||||
|
val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
|
AddMembersButton(tint, onAddMembersClick)
|
||||||
}
|
}
|
||||||
SectionItemView(minHeight = 50.dp) {
|
SectionItemView(minHeight = 54.dp) {
|
||||||
MemberRow(groupInfo.membership, user = true)
|
MemberRow(groupInfo.membership, user = true)
|
||||||
}
|
}
|
||||||
if (members.isNotEmpty()) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
MembersList(members, showMemberInfo)
|
MembersList(members, showMemberInfo)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
SectionView {
|
SectionView {
|
||||||
if (groupInfo.canEdit) {
|
|
||||||
SectionItemView(editGroupProfile) { EditGroupProfileButton() }
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
ClearChatButton(clearChat)
|
ClearChatButton(clearChat)
|
||||||
if (groupInfo.canDelete) {
|
if (groupInfo.canDelete) {
|
||||||
SectionDivider()
|
DeleteGroupButton(deleteGroup)
|
||||||
SectionItemView(deleteGroup) { DeleteGroupButton() }
|
|
||||||
}
|
}
|
||||||
if (groupInfo.membership.memberCurrent) {
|
if (groupInfo.membership.memberCurrent) {
|
||||||
SectionDivider()
|
LeaveGroupButton(leaveGroup)
|
||||||
SectionItemView(leaveGroup) { LeaveGroupButton() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), groupInfo.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), groupInfo.apiId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = 8.dp),
|
Modifier.padding(horizontal = 8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
@@ -212,57 +246,66 @@ fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AddMembersButton(tint: Color = MaterialTheme.colors.primary) {
|
private fun GroupPreferencesButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier.fillMaxSize(),
|
painterResource(R.drawable.ic_toggle_on),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
stringResource(R.string.group_preferences),
|
||||||
) {
|
click = onClick
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.Add,
|
|
||||||
stringResource(R.string.button_add_members),
|
|
||||||
tint = tint
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_add_members), color = tint)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_add),
|
||||||
|
stringResource(R.string.button_add_members),
|
||||||
|
onClick,
|
||||||
|
iconColor = tint,
|
||||||
|
textColor = tint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MembersList(members: List<GroupMember>, showMemberInfo: (GroupMember) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
members.forEachIndexed { index, member ->
|
members.forEachIndexed { index, member ->
|
||||||
SectionItemView({ showMemberInfo(member) }, minHeight = 50.dp) {
|
Divider()
|
||||||
|
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
|
||||||
MemberRow(member)
|
MemberRow(member)
|
||||||
}
|
}
|
||||||
if (index < members.lastIndex) {
|
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MemberRow(member: GroupMember, user: Boolean = false) {
|
private fun MemberRow(member: GroupMember, user: Boolean = false) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
Modifier.weight(1f).padding(end = DEFAULT_PADDING),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 46.dp, member.image)
|
ProfileImage(size = 46.dp, member.image)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
if (member.verified) {
|
||||||
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
MemberVerifiedShield()
|
||||||
)
|
}
|
||||||
|
Text(
|
||||||
|
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (member.memberIncognito) Indigo else Color.Unspecified
|
||||||
|
)
|
||||||
|
}
|
||||||
val s = member.memberStatus.shortText
|
val s = member.memberStatus.shortText
|
||||||
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
val statusDescr = if (user) String.format(generalGetString(R.string.group_info_member_you), s) else s
|
||||||
Text(
|
Text(
|
||||||
statusDescr,
|
statusDescr,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
@@ -271,75 +314,81 @@ fun MemberRow(member: GroupMember, user: Boolean = false) {
|
|||||||
}
|
}
|
||||||
val role = member.memberRole
|
val role = member.memberRole
|
||||||
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
if (role == GroupMemberRole.Owner || role == GroupMemberRole.Admin) {
|
||||||
Text(role.text, color = HighOrLowlight)
|
Text(role.text, color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkButton() {
|
private fun MemberVerifiedShield() {
|
||||||
Row(
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
|
||||||
Modifier
|
|
||||||
.fillMaxSize(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.Link,
|
|
||||||
stringResource(R.string.group_link),
|
|
||||||
tint = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.group_link), color = MaterialTheme.colors.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EditGroupProfileButton() {
|
private fun GroupLinkButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier
|
painterResource(R.drawable.ic_link),
|
||||||
.fillMaxSize(),
|
stringResource(R.string.group_link),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
onClick,
|
||||||
) {
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icon(
|
)
|
||||||
Icons.Outlined.Edit,
|
|
||||||
stringResource(R.string.button_edit_group_profile),
|
|
||||||
tint = MaterialTheme.colors.primary
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_edit_group_profile), color = MaterialTheme.colors.primary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LeaveGroupButton() {
|
private fun CreateGroupLinkButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier.fillMaxSize(),
|
painterResource(R.drawable.ic_add_link),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
stringResource(R.string.create_group_link),
|
||||||
) {
|
onClick,
|
||||||
Icon(
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icons.Outlined.Logout,
|
)
|
||||||
stringResource(R.string.button_leave_group),
|
|
||||||
tint = Color.Red
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.button_leave_group), color = Color.Red)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeleteGroupButton() {
|
fun EditGroupProfileButton(onClick: () -> Unit) {
|
||||||
Row(
|
SettingsActionItem(
|
||||||
Modifier.fillMaxSize(),
|
painterResource(R.drawable.ic_edit),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
stringResource(R.string.button_edit_group_profile),
|
||||||
) {
|
onClick,
|
||||||
Icon(
|
iconColor = MaterialTheme.colors.secondary
|
||||||
Icons.Outlined.Delete,
|
)
|
||||||
stringResource(R.string.button_delete_group),
|
}
|
||||||
tint = Color.Red
|
|
||||||
)
|
@Composable
|
||||||
Spacer(Modifier.size(8.dp))
|
private fun AddOrEditWelcomeMessage(welcomeMessage: String?, onClick: () -> Unit) {
|
||||||
Text(stringResource(R.string.button_delete_group), color = Color.Red)
|
val text = if (welcomeMessage == null) {
|
||||||
|
stringResource(R.string.button_add_welcome_message)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.button_welcome_message)
|
||||||
}
|
}
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_maps_ugc),
|
||||||
|
text,
|
||||||
|
onClick,
|
||||||
|
iconColor = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LeaveGroupButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_logout),
|
||||||
|
stringResource(R.string.button_leave_group),
|
||||||
|
onClick,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeleteGroupButton(onClick: () -> Unit) {
|
||||||
|
SettingsActionItem(
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
stringResource(R.string.button_delete_group),
|
||||||
|
onClick,
|
||||||
|
iconColor = Color.Red,
|
||||||
|
textColor = Color.Red
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@@ -349,13 +398,13 @@ fun PreviewGroupChatInfoLayout() {
|
|||||||
GroupChatInfoLayout(
|
GroupChatInfoLayout(
|
||||||
chat = Chat(
|
chat = Chat(
|
||||||
chatInfo = ChatInfo.Direct.sampleData,
|
chatInfo = ChatInfo.Direct.sampleData,
|
||||||
chatItems = arrayListOf(),
|
chatItems = arrayListOf()
|
||||||
serverInfo = Chat.ServerInfo(Chat.NetworkStatus.Error("agent BROKER TIMEOUT"))
|
|
||||||
),
|
),
|
||||||
groupInfo = GroupInfo.sampleData,
|
groupInfo = GroupInfo.sampleData,
|
||||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
groupLink = null,
|
||||||
|
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-25
@@ -1,39 +1,71 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.outlined.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.GroupInfo
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
|
||||||
import chat.simplex.app.ui.theme.SimpleButton
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.newchat.QRCode
|
import chat.simplex.app.views.newchat.QRCode
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?) {
|
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String?, GroupMemberRole?>) -> Unit) {
|
||||||
var groupLink by remember { mutableStateOf(connReqContact) }
|
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
|
||||||
|
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
|
||||||
|
var creatingLink by rememberSaveable { mutableStateOf(false) }
|
||||||
val cxt = LocalContext.current
|
val cxt = LocalContext.current
|
||||||
|
fun createLink() {
|
||||||
|
creatingLink = true
|
||||||
|
withApi {
|
||||||
|
val link = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
||||||
|
if (link != null) {
|
||||||
|
groupLink = link.first
|
||||||
|
groupLinkMemberRole.value = link.second
|
||||||
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
|
}
|
||||||
|
creatingLink = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (groupLink == null && !creatingLink) {
|
||||||
|
createLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
GroupLinkLayout(
|
GroupLinkLayout(
|
||||||
groupLink = groupLink,
|
groupLink = groupLink,
|
||||||
createLink = {
|
groupInfo,
|
||||||
withApi {
|
groupLinkMemberRole,
|
||||||
groupLink = chatModel.controller.apiCreateGroupLink(groupInfo.groupId)
|
creatingLink,
|
||||||
|
createLink = ::createLink,
|
||||||
|
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
||||||
|
updateLink = {
|
||||||
|
val role = groupLinkMemberRole.value
|
||||||
|
if (role != null) {
|
||||||
|
withBGApi {
|
||||||
|
val link = chatModel.controller.apiGroupLinkMemberRole(groupInfo.groupId, role)
|
||||||
|
if (link != null) {
|
||||||
|
groupLink = link.first
|
||||||
|
groupLinkMemberRole.value = link.second
|
||||||
|
onGroupLinkUpdated(groupLink to groupLinkMemberRole.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
share = { shareText(cxt, groupLink ?: return@GroupLinkLayout) },
|
|
||||||
deleteLink = {
|
deleteLink = {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.delete_link_question),
|
title = generalGetString(R.string.delete_link_question),
|
||||||
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
text = generalGetString(R.string.all_group_members_will_remain_connected),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
@@ -42,30 +74,38 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
|
|||||||
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
|
||||||
if (r) {
|
if (r) {
|
||||||
groupLink = null
|
groupLink = null
|
||||||
|
onGroupLinkUpdated(null to null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if (creatingLink) {
|
||||||
|
ProgressIndicator()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupLinkLayout(
|
fun GroupLinkLayout(
|
||||||
groupLink: String?,
|
groupLink: String?,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
groupLinkMemberRole: MutableState<GroupMemberRole?>,
|
||||||
|
creatingLink: Boolean,
|
||||||
createLink: () -> Unit,
|
createLink: () -> Unit,
|
||||||
share: () -> Unit,
|
share: () -> Unit,
|
||||||
|
updateLink: () -> Unit,
|
||||||
deleteLink: () -> Unit
|
deleteLink: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(horizontal = DEFAULT_PADDING),
|
Modifier
|
||||||
horizontalAlignment = Alignment.Start,
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.group_link), false)
|
AppBarTitle(stringResource(R.string.group_link))
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
stringResource(R.string.you_can_share_group_link_anybody_will_be_able_to_connect),
|
||||||
Modifier.padding(bottom = 12.dp),
|
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp),
|
||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
)
|
)
|
||||||
Column(
|
Column(
|
||||||
@@ -74,28 +114,70 @@ fun GroupLinkLayout(
|
|||||||
verticalArrangement = Arrangement.SpaceEvenly
|
verticalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
if (groupLink == null) {
|
if (groupLink == null) {
|
||||||
SimpleButton(stringResource(R.string.button_create_group_link), icon = Icons.Outlined.AddLink, click = createLink)
|
SimpleButton(stringResource(R.string.button_create_group_link), icon = painterResource(R.drawable.ic_add_link), disabled = creatingLink, click = createLink)
|
||||||
} else {
|
} else {
|
||||||
QRCode(groupLink, Modifier.weight(1f, fill = false).aspectRatio(1f))
|
RoleSelectionRow(groupInfo, groupLinkMemberRole)
|
||||||
|
var initialLaunch by remember { mutableStateOf(true) }
|
||||||
|
LaunchedEffect(groupLinkMemberRole.value) {
|
||||||
|
if (!initialLaunch) {
|
||||||
|
updateLink()
|
||||||
|
}
|
||||||
|
initialLaunch = false
|
||||||
|
}
|
||||||
|
QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.padding(vertical = 10.dp)
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
|
||||||
) {
|
) {
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.share_link),
|
stringResource(R.string.share_link),
|
||||||
icon = Icons.Outlined.Share,
|
icon = painterResource(R.drawable.ic_share),
|
||||||
click = share
|
click = share
|
||||||
)
|
)
|
||||||
SimpleButton(
|
SimpleButton(
|
||||||
stringResource(R.string.delete_link),
|
stringResource(R.string.delete_link),
|
||||||
icon = Icons.Outlined.Delete,
|
icon = painterResource(R.drawable.ic_delete),
|
||||||
color = Color.Red,
|
color = Color.Red,
|
||||||
click = deleteLink
|
click = deleteLink
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<GroupMemberRole?>, enabled: Boolean = true) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
|
||||||
|
ExposedDropDownSettingRow(
|
||||||
|
generalGetString(R.string.initial_member_role),
|
||||||
|
values,
|
||||||
|
selectedRole,
|
||||||
|
icon = null,
|
||||||
|
enabled = rememberUpdatedState(enabled)
|
||||||
|
) { selectedRole.value = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProgressIndicator() {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(30.dp),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+87
-49
@@ -1,20 +1,19 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
import InfoRow
|
import InfoRow
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
import SectionItemView
|
import SectionDividerSpaced
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -23,17 +22,17 @@ import androidx.compose.ui.unit.dp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.SimplexServers
|
import chat.simplex.app.views.chat.*
|
||||||
import chat.simplex.app.views.chat.SwitchAddressButton
|
|
||||||
import chat.simplex.app.views.chatlist.openChat
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.usersettings.SettingsActionItem
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GroupMemberInfoView(
|
fun GroupMemberInfoView(
|
||||||
groupInfo: GroupInfo,
|
groupInfo: GroupInfo,
|
||||||
member: GroupMember,
|
member: GroupMember,
|
||||||
connStats: ConnectionStats?,
|
connStats: ConnectionStats?,
|
||||||
|
connectionCode: String?,
|
||||||
chatModel: ChatModel,
|
chatModel: ChatModel,
|
||||||
close: () -> Unit,
|
close: () -> Unit,
|
||||||
closeAll: () -> Unit, // Close all open windows up to ChatView
|
closeAll: () -> Unit, // Close all open windows up to ChatView
|
||||||
@@ -49,20 +48,20 @@ fun GroupMemberInfoView(
|
|||||||
connStats,
|
connStats,
|
||||||
newRole,
|
newRole,
|
||||||
developerTools,
|
developerTools,
|
||||||
|
connectionCode,
|
||||||
|
getContactChat = { chatModel.getContactChat(it) },
|
||||||
openDirectChat = {
|
openDirectChat = {
|
||||||
withApi {
|
withApi {
|
||||||
val oldChat = chatModel.getContactChat(member.memberContactId ?: return@withApi)
|
val c = chatModel.controller.apiGetChat(ChatType.Direct, it)
|
||||||
if (oldChat != null) {
|
if (c != null) {
|
||||||
openChat(oldChat.chatInfo, chatModel)
|
if (chatModel.getContactChat(it) == null) {
|
||||||
} else {
|
chatModel.addChat(c)
|
||||||
var newChat = chatModel.controller.apiGetChat(ChatType.Direct, member.memberContactId) ?: return@withApi
|
}
|
||||||
// TODO it's not correct to blindly set network status to connected - we should manage network status in model / backend
|
|
||||||
newChat = newChat.copy(serverInfo = Chat.ServerInfo(networkStatus = Chat.NetworkStatus.Connected()))
|
|
||||||
chatModel.addChat(newChat)
|
|
||||||
chatModel.chatItems.clear()
|
chatModel.chatItems.clear()
|
||||||
chatModel.chatId.value = newChat.id
|
chatModel.chatItems.addAll(c.chatItems)
|
||||||
|
chatModel.chatId.value = c.id
|
||||||
|
closeAll()
|
||||||
}
|
}
|
||||||
closeAll()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
|
||||||
@@ -85,13 +84,39 @@ fun GroupMemberInfoView(
|
|||||||
},
|
},
|
||||||
switchMemberAddress = {
|
switchMemberAddress = {
|
||||||
switchMemberAddress(chatModel, groupInfo, member)
|
switchMemberAddress(chatModel, groupInfo, member)
|
||||||
|
},
|
||||||
|
verifyClicked = {
|
||||||
|
ModalManager.shared.showModalCloseable { close ->
|
||||||
|
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
|
||||||
|
VerifyCodeView(
|
||||||
|
mem.displayName,
|
||||||
|
connectionCode,
|
||||||
|
mem.verified,
|
||||||
|
verify = { code ->
|
||||||
|
chatModel.controller.apiVerifyGroupMember(mem.groupId, mem.groupMemberId, code)?.let { r ->
|
||||||
|
val (verified, existingCode) = r
|
||||||
|
chatModel.upsertGroupMember(
|
||||||
|
groupInfo,
|
||||||
|
mem.copy(
|
||||||
|
activeConn = mem.activeConn?.copy(
|
||||||
|
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
r
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.button_remove_member),
|
title = generalGetString(R.string.button_remove_member),
|
||||||
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
text = generalGetString(R.string.member_will_be_removed_from_group_cannot_be_undone),
|
||||||
confirmText = generalGetString(R.string.remove_member_confirmation),
|
confirmText = generalGetString(R.string.remove_member_confirmation),
|
||||||
@@ -103,7 +128,8 @@ fun removeMemberDialog(groupInfo: GroupInfo, member: GroupMember, chatModel: Cha
|
|||||||
}
|
}
|
||||||
close?.invoke()
|
close?.invoke()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,16 +140,18 @@ fun GroupMemberInfoLayout(
|
|||||||
connStats: ConnectionStats?,
|
connStats: ConnectionStats?,
|
||||||
newRole: MutableState<GroupMemberRole>,
|
newRole: MutableState<GroupMemberRole>,
|
||||||
developerTools: Boolean,
|
developerTools: Boolean,
|
||||||
openDirectChat: () -> Unit,
|
connectionCode: String?,
|
||||||
|
getContactChat: (Long) -> Chat?,
|
||||||
|
openDirectChat: (Long) -> Unit,
|
||||||
removeMember: () -> Unit,
|
removeMember: () -> Unit,
|
||||||
onRoleSelected: (GroupMemberRole) -> Unit,
|
onRoleSelected: (GroupMemberRole) -> Unit,
|
||||||
switchMemberAddress: () -> Unit,
|
switchMemberAddress: () -> Unit,
|
||||||
|
verifyClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
@@ -133,45 +161,48 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
|
|
||||||
SectionView {
|
if (member.memberActive) {
|
||||||
OpenChatButton(openDirectChat)
|
val contactId = member.memberContactId
|
||||||
|
if (contactId != null) {
|
||||||
|
SectionView {
|
||||||
|
val chat = getContactChat(contactId)
|
||||||
|
if ((chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) || groupInfo.fullGroupPreferences.directMessages.on) {
|
||||||
|
OpenChatButton(onClick = { openDirectChat(contactId) })
|
||||||
|
}
|
||||||
|
if (connectionCode != null) {
|
||||||
|
VerifyCodeButton(member.verified, verifyClicked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionSpacer()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
SectionView(title = stringResource(R.string.member_info_section_title_member)) {
|
||||||
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
InfoRow(stringResource(R.string.info_row_group), groupInfo.displayName)
|
||||||
SectionDivider()
|
|
||||||
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
val roles = remember { member.canChangeRoleTo(groupInfo) }
|
||||||
if (roles != null) {
|
if (roles != null) {
|
||||||
SectionItemView {
|
RoleSelectionRow(roles, newRole, onRoleSelected)
|
||||||
RoleSelectionRow(roles, newRole, onRoleSelected)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
InfoRow(stringResource(R.string.role_in_group), member.memberRole.text)
|
||||||
}
|
}
|
||||||
val conn = member.activeConn
|
val conn = member.activeConn
|
||||||
if (conn != null) {
|
if (conn != null) {
|
||||||
SectionDivider()
|
|
||||||
val connLevelDesc =
|
val connLevelDesc =
|
||||||
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
if (conn.connLevel == 0) stringResource(R.string.conn_level_desc_direct)
|
||||||
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
else String.format(generalGetString(R.string.conn_level_desc_indirect), conn.connLevel)
|
||||||
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
InfoRow(stringResource(R.string.info_row_connection), connLevelDesc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
if (connStats != null) {
|
||||||
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
SectionDividerSpaced()
|
||||||
if (developerTools) {
|
SectionView(title = stringResource(R.string.conn_stats_section_title_servers)) {
|
||||||
SwitchAddressButton(switchMemberAddress)
|
SwitchAddressButton(switchMemberAddress)
|
||||||
SectionDivider()
|
|
||||||
}
|
|
||||||
if (connStats != null) {
|
|
||||||
val rcvServers = connStats.rcvServers
|
val rcvServers = connStats.rcvServers
|
||||||
val sndServers = connStats.sndServers
|
val sndServers = connStats.sndServers
|
||||||
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
if ((rcvServers != null && rcvServers.isNotEmpty()) || (sndServers != null && sndServers.isNotEmpty())) {
|
||||||
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
if (rcvServers != null && rcvServers.isNotEmpty()) {
|
||||||
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
SimplexServers(stringResource(R.string.receiving_via), rcvServers)
|
||||||
if (sndServers != null && sndServers.isNotEmpty()) {
|
if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
SectionDivider()
|
|
||||||
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
SimplexServers(stringResource(R.string.sending_via), sndServers)
|
||||||
}
|
}
|
||||||
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
} else if (sndServers != null && sndServers.isNotEmpty()) {
|
||||||
@@ -180,23 +211,22 @@ fun GroupMemberInfoLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
|
|
||||||
if (member.canBeRemoved(groupInfo)) {
|
if (member.canBeRemoved(groupInfo)) {
|
||||||
|
SectionDividerSpaced(maxBottomPadding = false)
|
||||||
SectionView {
|
SectionView {
|
||||||
RemoveMemberButton(removeMember)
|
RemoveMemberButton(removeMember)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (developerTools) {
|
if (developerTools) {
|
||||||
|
SectionDividerSpaced()
|
||||||
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
SectionView(title = stringResource(R.string.section_title_for_console)) {
|
||||||
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
InfoRow(stringResource(R.string.info_row_local_name), member.localDisplayName)
|
||||||
SectionDivider()
|
|
||||||
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
InfoRow(stringResource(R.string.info_row_database_id), member.groupMemberId.toString())
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,12 +237,17 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
ProfileImage(size = 192.dp, member.image, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||||
Text(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
if (member.verified) {
|
||||||
color = MaterialTheme.colors.onBackground,
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.padding(end = 6.dp, top = 4.dp).size(24.dp), tint = MaterialTheme.colors.secondary)
|
||||||
maxLines = 1,
|
}
|
||||||
overflow = TextOverflow.Ellipsis
|
Text(
|
||||||
)
|
member.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = MaterialTheme.colors.onBackground,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||||
Text(
|
Text(
|
||||||
member.fullName, style = MaterialTheme.typography.h2,
|
member.fullName, style = MaterialTheme.typography.h2,
|
||||||
@@ -227,7 +262,7 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun RemoveMemberButton(onClick: () -> Unit) {
|
fun RemoveMemberButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.button_remove_member),
|
stringResource(R.string.button_remove_member),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -238,7 +273,7 @@ fun RemoveMemberButton(onClick: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun OpenChatButton(onClick: () -> Unit) {
|
fun OpenChatButton(onClick: () -> Unit) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Message,
|
painterResource(R.drawable.ic_chat),
|
||||||
stringResource(R.string.button_send_direct_message),
|
stringResource(R.string.button_send_direct_message),
|
||||||
click = onClick,
|
click = onClick,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
@@ -302,10 +337,13 @@ fun PreviewGroupMemberInfoLayout() {
|
|||||||
connStats = null,
|
connStats = null,
|
||||||
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
newRole = remember { mutableStateOf(GroupMemberRole.Member) },
|
||||||
developerTools = false,
|
developerTools = false,
|
||||||
|
connectionCode = "123",
|
||||||
|
getContactChat = { Chat.sampleData },
|
||||||
openDirectChat = {},
|
openDirectChat = {},
|
||||||
removeMember = {},
|
removeMember = {},
|
||||||
onRoleSelected = {},
|
onRoleSelected = {},
|
||||||
switchMemberAddress = {},
|
switchMemberAddress = {},
|
||||||
|
verifyClicked = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import InfoRow
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
|
import SectionItemView
|
||||||
|
import SectionTextFooter
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.TimedMessagesTTLPicker
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.PreferenceToggleWithIcon
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupPreferencesView(m: ChatModel, chatId: String, close: () -> Unit,) {
|
||||||
|
val groupInfo = remember { derivedStateOf { (m.getChat(chatId)?.chatInfo as? ChatInfo.Group)?.groupInfo } }
|
||||||
|
val gInfo = groupInfo.value ?: return
|
||||||
|
var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) }
|
||||||
|
var currentPreferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(preferences) }
|
||||||
|
|
||||||
|
fun savePrefs(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences())
|
||||||
|
val gInfo = m.controller.apiUpdateGroup(gInfo.groupId, gp)
|
||||||
|
if (gInfo != null) {
|
||||||
|
m.updateGroup(gInfo)
|
||||||
|
currentPreferences = preferences
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (preferences == currentPreferences) close()
|
||||||
|
else showUnsavedChangesAlert({ savePrefs(close) }, close)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
GroupPreferencesLayout(
|
||||||
|
preferences,
|
||||||
|
currentPreferences,
|
||||||
|
gInfo,
|
||||||
|
applyPrefs = { prefs ->
|
||||||
|
preferences = prefs
|
||||||
|
},
|
||||||
|
reset = {
|
||||||
|
preferences = currentPreferences
|
||||||
|
},
|
||||||
|
savePrefs = ::savePrefs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupPreferencesLayout(
|
||||||
|
preferences: FullGroupPreferences,
|
||||||
|
currentPreferences: FullGroupPreferences,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
applyPrefs: (FullGroupPreferences) -> Unit,
|
||||||
|
reset: () -> Unit,
|
||||||
|
savePrefs: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.group_preferences))
|
||||||
|
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
|
||||||
|
val onTTLUpdated = { ttl: Int? ->
|
||||||
|
applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl ?: 86400)))
|
||||||
|
}
|
||||||
|
FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable ->
|
||||||
|
if (enable == GroupFeatureEnabled.ON) {
|
||||||
|
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400)))
|
||||||
|
} else {
|
||||||
|
applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = currentPreferences.timedMessages.ttl)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) }
|
||||||
|
FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) {
|
||||||
|
applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) }
|
||||||
|
FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) {
|
||||||
|
applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
SectionDividerSpaced(true, maxBottomPadding = false)
|
||||||
|
val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) }
|
||||||
|
FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) {
|
||||||
|
applyPrefs(preferences.copy(voice = GroupPreference(enable = it)))
|
||||||
|
}
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false)
|
||||||
|
ResetSaveButtons(
|
||||||
|
reset = reset,
|
||||||
|
save = savePrefs,
|
||||||
|
disabled = preferences == currentPreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FeatureSection(
|
||||||
|
feature: GroupFeature,
|
||||||
|
enableFeature: State<GroupFeatureEnabled>,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
preferences: FullGroupPreferences,
|
||||||
|
onTTLUpdated: (Int?) -> Unit,
|
||||||
|
onSelected: (GroupFeatureEnabled) -> Unit
|
||||||
|
) {
|
||||||
|
SectionView {
|
||||||
|
val on = enableFeature.value == GroupFeatureEnabled.ON
|
||||||
|
val icon = if (on) feature.iconFilled() else feature.icon
|
||||||
|
val iconTint = if (on) SimplexGreen else MaterialTheme.colors.secondary
|
||||||
|
val timedOn = feature == GroupFeature.TimedMessages && enableFeature.value == GroupFeatureEnabled.ON
|
||||||
|
if (groupInfo.canEdit) {
|
||||||
|
PreferenceToggleWithIcon(
|
||||||
|
feature.text,
|
||||||
|
icon,
|
||||||
|
iconTint,
|
||||||
|
enableFeature.value == GroupFeatureEnabled.ON,
|
||||||
|
) { checked ->
|
||||||
|
onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF)
|
||||||
|
}
|
||||||
|
if (timedOn) {
|
||||||
|
val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) }
|
||||||
|
TimedMessagesTTLPicker(ttl, onTTLUpdated)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
InfoRow(
|
||||||
|
feature.text,
|
||||||
|
enableFeature.value.text,
|
||||||
|
icon = icon,
|
||||||
|
iconTint = iconTint,
|
||||||
|
)
|
||||||
|
if (timedOn) {
|
||||||
|
InfoRow(generalGetString(R.string.delete_after), TimedMessagesPreference.ttlText(preferences.timedMessages.ttl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ResetSaveButtons(reset: () -> Unit, save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(reset, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.reset_verb), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_notify_group_members), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
+81
-49
@@ -1,7 +1,7 @@
|
|||||||
package chat.simplex.app.views.chat.group
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -23,6 +24,7 @@ import chat.simplex.app.ui.theme.*
|
|||||||
import chat.simplex.app.views.ProfileNameField
|
import chat.simplex.app.views.ProfileNameField
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.isValidDisplayName
|
import chat.simplex.app.views.isValidDisplayName
|
||||||
|
import chat.simplex.app.views.onboarding.ReadableText
|
||||||
import chat.simplex.app.views.usersettings.*
|
import chat.simplex.app.views.usersettings.*
|
||||||
import com.google.accompanist.insets.ProvideWindowInsets
|
import com.google.accompanist.insets.ProvideWindowInsets
|
||||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||||
@@ -53,14 +55,32 @@ fun GroupProfileLayout(
|
|||||||
saveProfile: (GroupProfile) -> Unit,
|
saveProfile: (GroupProfile) -> Unit,
|
||||||
) {
|
) {
|
||||||
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
|
||||||
val displayName = remember { mutableStateOf(groupProfile.displayName) }
|
val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) }
|
||||||
val fullName = remember { mutableStateOf(groupProfile.fullName) }
|
val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) }
|
||||||
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
val chosenImage = rememberSaveable { mutableStateOf<Uri?>(null) }
|
||||||
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
val profileImage = rememberSaveable { mutableStateOf(groupProfile.image) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val dataUnchanged =
|
||||||
|
displayName.value == groupProfile.displayName &&
|
||||||
|
fullName.value == groupProfile.fullName &&
|
||||||
|
groupProfile.image == profileImage.value
|
||||||
|
val closeWithAlert = {
|
||||||
|
if (dataUnchanged || !(displayName.value.isNotEmpty() && isValidDisplayName(displayName.value))) {
|
||||||
|
close()
|
||||||
|
} else {
|
||||||
|
showUnsavedChangesAlert({
|
||||||
|
saveProfile(
|
||||||
|
groupProfile.copy(
|
||||||
|
displayName = displayName.value,
|
||||||
|
fullName = fullName.value,
|
||||||
|
image = profileImage.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, close)
|
||||||
|
}
|
||||||
|
}
|
||||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
|
||||||
ModalBottomSheetLayout(
|
ModalBottomSheetLayout(
|
||||||
scrimColor = Color.Black.copy(alpha = 0.12F),
|
scrimColor = Color.Black.copy(alpha = 0.12F),
|
||||||
@@ -76,23 +96,16 @@ fun GroupProfileLayout(
|
|||||||
sheetState = bottomSheetModalState,
|
sheetState = bottomSheetModalState,
|
||||||
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
|
||||||
) {
|
) {
|
||||||
ModalView(close = close) {
|
ModalView(close = closeWithAlert) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = DEFAULT_PADDING),
|
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
Text(
|
|
||||||
stringResource(R.string.group_profile_is_stored_on_members_devices),
|
|
||||||
Modifier.padding(bottom = 24.dp),
|
|
||||||
color = MaterialTheme.colors.onBackground,
|
|
||||||
lineHeight = 22.sp
|
|
||||||
)
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth()
|
||||||
horizontalAlignment = Alignment.Start
|
.padding(horizontal = DEFAULT_PADDING)
|
||||||
) {
|
) {
|
||||||
|
ReadableText(R.string.group_profile_is_stored_on_members_devices, TextAlign.Center)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -101,7 +114,7 @@ fun GroupProfileLayout(
|
|||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.TopEnd) {
|
Box(contentAlignment = Alignment.TopEnd) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center) {
|
||||||
ProfileImage(192.dp, profileImage.value)
|
ProfileImage(108.dp, profileImage.value, color = MaterialTheme.colors.secondary.copy(alpha = 0.1f))
|
||||||
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
EditImageButton { scope.launch { bottomSheetModalState.show() } }
|
||||||
}
|
}
|
||||||
if (profileImage.value != null) {
|
if (profileImage.value != null) {
|
||||||
@@ -109,45 +122,54 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
stringResource(R.string.group_display_name_field),
|
Text(
|
||||||
Modifier.padding(bottom = 3.dp)
|
stringResource(R.string.group_display_name_field),
|
||||||
)
|
fontSize = 16.sp
|
||||||
ProfileNameField(displayName, focusRequester)
|
)
|
||||||
val errorText = if (!isValidDisplayName(displayName.value)) stringResource(R.string.display_name_cannot_contain_whitespace) else ""
|
if (!isValidDisplayName(displayName.value)) {
|
||||||
Text(
|
Spacer(Modifier.size(DEFAULT_PADDING_HALF))
|
||||||
errorText,
|
|
||||||
fontSize = 15.sp,
|
|
||||||
color = MaterialTheme.colors.error
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.group_full_name_field),
|
|
||||||
Modifier.padding(bottom = 5.dp)
|
|
||||||
)
|
|
||||||
ProfileNameField(fullName)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Row {
|
|
||||||
TextButton(stringResource(R.string.cancel_verb)) {
|
|
||||||
close.invoke()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.padding(horizontal = 8.dp))
|
|
||||||
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
|
||||||
if (enabled) {
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.save_group_profile),
|
stringResource(R.string.no_spaces),
|
||||||
modifier = Modifier.clickable { saveProfile(GroupProfile(displayName.value, fullName.value, profileImage.value)) },
|
fontSize = 16.sp,
|
||||||
color = MaterialTheme.colors.primary
|
color = Color.Red
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.save_group_profile),
|
|
||||||
color = HighOrLowlight
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.group_full_name_field),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF)
|
||||||
|
)
|
||||||
|
ProfileNameField(fullName)
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
val enabled = !dataUnchanged && displayName.value.isNotEmpty() && isValidDisplayName(displayName.value)
|
||||||
|
if (enabled) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.save_group_profile),
|
||||||
|
modifier = Modifier.clickable {
|
||||||
|
saveProfile(
|
||||||
|
groupProfile.copy(
|
||||||
|
displayName = displayName.value,
|
||||||
|
fullName = fullName.value,
|
||||||
|
image = profileImage.value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
color = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.save_group_profile),
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SectionBottomSpacer()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
delay(300)
|
delay(300)
|
||||||
focusRequester.requestFocus()
|
focusRequester.requestFocus()
|
||||||
@@ -158,6 +180,16 @@ fun GroupProfileLayout(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_preferences_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_notify_group_members),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
|||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
package chat.simplex.app.views.chat.group
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
|
import SectionItemView
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GroupWelcomeView(m: ChatModel, groupInfo: GroupInfo, close: () -> Unit) {
|
||||||
|
var groupInfo by remember { mutableStateOf(groupInfo) }
|
||||||
|
val welcomeText = remember { mutableStateOf(groupInfo.groupProfile.description ?: "") }
|
||||||
|
|
||||||
|
fun save(afterSave: () -> Unit = {}) {
|
||||||
|
withApi {
|
||||||
|
var welcome: String? = welcomeText.value.trim('\n', ' ')
|
||||||
|
if (welcome?.length == 0) {
|
||||||
|
welcome = null
|
||||||
|
}
|
||||||
|
val groupProfileUpdated = groupInfo.groupProfile.copy(description = welcome)
|
||||||
|
val res = m.controller.apiUpdateGroup(groupInfo.groupId, groupProfileUpdated)
|
||||||
|
if (res != null) {
|
||||||
|
groupInfo = res
|
||||||
|
m.updateGroup(res)
|
||||||
|
welcomeText.value = welcome ?: ""
|
||||||
|
}
|
||||||
|
afterSave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalView(
|
||||||
|
close = {
|
||||||
|
if (welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)) close()
|
||||||
|
else showUnsavedChangesAlert({ save(close) }, close)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
GroupWelcomeLayout(
|
||||||
|
welcomeText,
|
||||||
|
groupInfo,
|
||||||
|
save = ::save
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GroupWelcomeLayout(
|
||||||
|
welcomeText: MutableState<String>,
|
||||||
|
groupInfo: GroupInfo,
|
||||||
|
save: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
AppBarTitle(stringResource(R.string.group_welcome_title))
|
||||||
|
val welcomeText = remember { welcomeText }
|
||||||
|
TextEditor(Modifier.padding(horizontal = DEFAULT_PADDING).height(160.dp), text = welcomeText)
|
||||||
|
SectionSpacer()
|
||||||
|
SaveButton(
|
||||||
|
save = save,
|
||||||
|
disabled = welcomeText.value == groupInfo.groupProfile.description || (welcomeText.value == "" && groupInfo.groupProfile.description == null)
|
||||||
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SaveButton(save: () -> Unit, disabled: Boolean) {
|
||||||
|
SectionView {
|
||||||
|
SectionItemView(save, disabled = disabled) {
|
||||||
|
Text(stringResource(R.string.save_and_update_group_profile), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialogStacked(
|
||||||
|
title = generalGetString(R.string.save_welcome_message_question),
|
||||||
|
confirmText = generalGetString(R.string.save_and_update_group_profile),
|
||||||
|
dismissText = generalGetString(R.string.exit_without_saving),
|
||||||
|
onConfirm = save,
|
||||||
|
onDismiss = revert,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PhoneInTalk
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
@@ -24,28 +21,28 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
.padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@Composable fun ConnectingCallIcon() = Icon(Icons.Outlined.SettingsPhone, stringResource(R.string.icon_descr_call_connecting), tint = Color.Green)
|
@Composable fun ConnectingCallIcon() = Icon(painterResource(R.drawable.ic_settings_phone), stringResource(R.string.icon_descr_call_connecting), tint = SimplexGreen)
|
||||||
when (status) {
|
when (status) {
|
||||||
CICallStatus.Pending -> if (sent) {
|
CICallStatus.Pending -> if (sent) {
|
||||||
Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_pending_sent))
|
Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_pending_sent))
|
||||||
} else {
|
} else {
|
||||||
AcceptCallButton(cInfo, acceptCall)
|
AcceptCallButton(cInfo, acceptCall)
|
||||||
}
|
}
|
||||||
CICallStatus.Missed -> Icon(Icons.Outlined.Call, stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
CICallStatus.Missed -> Icon(painterResource(R.drawable.ic_call), stringResource(R.string.icon_descr_call_missed), tint = Color.Red)
|
||||||
CICallStatus.Rejected -> Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
CICallStatus.Rejected -> Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_rejected), tint = Color.Red)
|
||||||
CICallStatus.Accepted -> ConnectingCallIcon()
|
CICallStatus.Accepted -> ConnectingCallIcon()
|
||||||
CICallStatus.Negotiated -> ConnectingCallIcon()
|
CICallStatus.Negotiated -> ConnectingCallIcon()
|
||||||
CICallStatus.Progress -> Icon(Icons.Filled.PhoneInTalk, stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
CICallStatus.Progress -> Icon(painterResource(R.drawable.ic_phone_in_talk_filled), stringResource(R.string.icon_descr_call_progress), tint = SimplexGreen)
|
||||||
CICallStatus.Ended -> Row {
|
CICallStatus.Ended -> Row {
|
||||||
Icon(Icons.Outlined.CallEnd, stringResource(R.string.icon_descr_call_ended), tint = HighOrLowlight, modifier = Modifier.padding(end = 4.dp))
|
Icon(painterResource(R.drawable.ic_call_end), stringResource(R.string.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp))
|
||||||
Text(status.duration(duration), color = HighOrLowlight)
|
Text(durationText(duration), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
CICallStatus.Error -> {}
|
CICallStatus.Error -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
cItem.timestampText,
|
cItem.timestampText,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
)
|
)
|
||||||
@@ -55,9 +52,9 @@ fun CICallItemView(cInfo: ChatInfo, cItem: ChatItem, status: CICallStatus, durat
|
|||||||
@Composable
|
@Composable
|
||||||
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||||
if (cInfo is ChatInfo.Direct) {
|
if (cInfo is ChatInfo.Direct) {
|
||||||
SimpleButton(stringResource(R.string.answer_call), Icons.Outlined.RingVolume) { acceptCall(cInfo.contact) }
|
SimpleButton(stringResource(R.string.answer_call), painterResource(R.drawable.ic_ring_volume)) { acceptCall(cInfo.contact) }
|
||||||
} else {
|
} else {
|
||||||
Icon(Icons.Outlined.RingVolume, stringResource(R.string.answer_call), tint = HighOrLowlight)
|
Icon(painterResource(R.drawable.ic_ring_volume), stringResource(R.string.answer_call), tint = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
// if case let .direct(contact) = chatInfo {
|
// if case let .direct(contact) = chatInfo {
|
||||||
// Button {
|
// Button {
|
||||||
@@ -154,4 +151,4 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
|||||||
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
// Image(systemName: "phone.arrow.down.left").foregroundColor(.secondary)
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIChatFeatureView(
|
||||||
|
chatItem: ChatItem,
|
||||||
|
feature: Feature,
|
||||||
|
iconColor: Color,
|
||||||
|
icon: Painter? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
|
||||||
|
Text(
|
||||||
|
chatEventText(chatItem),
|
||||||
|
Modifier,
|
||||||
|
// this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,37 +13,39 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIEventView(ci: ChatItem) {
|
fun CIEventView(ci: ChatItem) {
|
||||||
fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) {
|
@Composable
|
||||||
return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) }
|
fun chatEventTextView(text: AnnotatedString) {
|
||||||
|
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
|
||||||
}
|
}
|
||||||
|
Row(
|
||||||
Surface {
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
Row(
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
) {
|
||||||
verticalAlignment = Alignment.Bottom
|
val memberDisplayName = ci.memberDisplayName
|
||||||
) {
|
if (memberDisplayName != null) {
|
||||||
Text(
|
chatEventTextView(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
val memberDisplayName = ci.memberDisplayName
|
withStyle(chatEventStyle) { append(memberDisplayName) }
|
||||||
if (memberDisplayName != null) {
|
|
||||||
withGroupEventStyle(this, memberDisplayName)
|
|
||||||
append(" ")
|
|
||||||
}
|
|
||||||
withGroupEventStyle(this, ci.content.text)
|
|
||||||
append(" ")
|
append(" ")
|
||||||
withGroupEventStyle(this, ci.timestampText)
|
}.plus(chatEventText(ci))
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
chatEventTextView(chatEventText(ci))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary)
|
||||||
|
|
||||||
|
fun chatEventText(ci: ChatItem): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(chatEventStyle) { append(ci.content.text + " " + ci.timestampText) }
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Preview(
|
@Preview(
|
||||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIFeaturePreferenceView(
|
||||||
|
chatItem: ChatItem,
|
||||||
|
contact: Contact?,
|
||||||
|
feature: ChatFeature,
|
||||||
|
allowed: FeatureAllowed,
|
||||||
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) {
|
||||||
|
val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp)
|
||||||
|
val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null
|
||||||
|
val acceptTextId = if (setParam) R.string.accept_feature_set_1_day else R.string.accept_feature
|
||||||
|
val param = if (setParam) 86400 else null
|
||||||
|
val annotatedText = buildAnnotatedString {
|
||||||
|
withStyle(chatEventStyle) { append(chatItem.content.text + " ") }
|
||||||
|
withAnnotation(tag = "Accept", annotation = "Accept") {
|
||||||
|
withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") }
|
||||||
|
}
|
||||||
|
withStyle(chatEventStyle) { append(chatItem.timestampText) }
|
||||||
|
}
|
||||||
|
fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty()
|
||||||
|
ClickableText(
|
||||||
|
annotatedText,
|
||||||
|
onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } },
|
||||||
|
shouldConsumeEvent = ::accept
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(chatItem.content.text + " " + chatItem.timestampText,
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,18 @@ package chat.simplex.app.views.chat.item
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.*
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -37,14 +36,14 @@ fun CIFileView(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun fileIcon(
|
fun fileIcon(
|
||||||
innerIcon: ImageVector? = null,
|
innerIcon: Painter? = null,
|
||||||
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
color: Color = if (isInDarkTheme()) FileDark else FileLight
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
painterResource(R.drawable.ic_draft_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
stringResource(R.string.icon_descr_file),
|
||||||
Modifier.fillMaxSize(),
|
Modifier.fillMaxSize(),
|
||||||
tint = color
|
tint = color
|
||||||
@@ -64,7 +63,7 @@ fun CIFileView(
|
|||||||
|
|
||||||
fun fileSizeValid(): Boolean {
|
fun fileSizeValid(): Boolean {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
return file.fileSize <= MAX_FILE_SIZE
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -72,22 +71,30 @@ fun CIFileView(
|
|||||||
fun fileAction() {
|
fun fileAction() {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.RcvInvitation -> {
|
is CIFileStatus.RcvInvitation -> {
|
||||||
if (fileSizeValid()) {
|
if (fileSizeValid()) {
|
||||||
receiveFile(file.fileId)
|
receiveFile(file.fileId)
|
||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CIFileStatus.RcvAccepted ->
|
is CIFileStatus.RcvAccepted ->
|
||||||
AlertManager.shared.showAlertMsg(
|
when (file.fileProtocol) {
|
||||||
generalGetString(R.string.waiting_for_file),
|
FileProtocol.XFTP ->
|
||||||
String.format(generalGetString(R.string.file_will_be_received_when_contact_is_online), MAX_FILE_SIZE)
|
AlertManager.shared.showAlertMsg(
|
||||||
)
|
generalGetString(R.string.waiting_for_file),
|
||||||
CIFileStatus.RcvComplete -> {
|
generalGetString(R.string.file_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_file),
|
||||||
|
generalGetString(R.string.file_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvComplete -> {
|
||||||
val filePath = getLoadedFilePath(context, file)
|
val filePath = getLoadedFilePath(context, file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
saveFileLauncher.launch(file.fileName)
|
saveFileLauncher.launch(file.fileName)
|
||||||
@@ -105,10 +112,24 @@ fun CIFileView(
|
|||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
Modifier.size(32.dp),
|
Modifier.size(32.dp),
|
||||||
color = if (isInDarkTheme()) FileDark else FileLight,
|
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||||
strokeWidth = 4.dp
|
strokeWidth = 3.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun progressCircle(progress: Long, total: Long) {
|
||||||
|
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||||
|
val strokeColor = if (isInDarkTheme()) FileDark else FileLight
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = Color.Transparent,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun fileIndicator() {
|
fun fileIndicator() {
|
||||||
Box(
|
Box(
|
||||||
@@ -120,19 +141,34 @@ fun CIFileView(
|
|||||||
) {
|
) {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.SndStored -> fileIcon()
|
is CIFileStatus.SndStored ->
|
||||||
CIFileStatus.SndTransfer -> progressIndicator()
|
when (file.fileProtocol) {
|
||||||
CIFileStatus.SndComplete -> fileIcon(innerIcon = Icons.Filled.Check)
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
CIFileStatus.SndCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
FileProtocol.SMP -> fileIcon()
|
||||||
CIFileStatus.RcvInvitation ->
|
}
|
||||||
|
is CIFileStatus.SndTransfer ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
|
FileProtocol.SMP -> progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndComplete -> fileIcon(innerIcon = painterResource(R.drawable.ic_check_filled))
|
||||||
|
is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.RcvInvitation ->
|
||||||
if (fileSizeValid())
|
if (fileSizeValid())
|
||||||
fileIcon(innerIcon = Icons.Outlined.ArrowDownward, color = MaterialTheme.colors.primary)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_arrow_downward), color = MaterialTheme.colors.primary)
|
||||||
else
|
else
|
||||||
fileIcon(innerIcon = Icons.Outlined.PriorityHigh, color = WarningOrange)
|
fileIcon(innerIcon = painterResource(R.drawable.ic_priority_high), color = WarningOrange)
|
||||||
CIFileStatus.RcvAccepted -> fileIcon(innerIcon = Icons.Outlined.MoreHoriz)
|
is CIFileStatus.RcvAccepted -> fileIcon(innerIcon = painterResource(R.drawable.ic_more_horiz))
|
||||||
CIFileStatus.RcvTransfer -> progressIndicator()
|
is CIFileStatus.RcvTransfer ->
|
||||||
CIFileStatus.RcvComplete -> fileIcon()
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
CIFileStatus.RcvCancelled -> fileIcon(innerIcon = Icons.Outlined.Close)
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
|
} else {
|
||||||
|
progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvComplete -> fileIcon()
|
||||||
|
is CIFileStatus.RcvCancelled -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
|
is CIFileStatus.RcvError -> fileIcon(innerIcon = painterResource(R.drawable.ic_close))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fileIcon()
|
fileIcon()
|
||||||
@@ -151,16 +187,14 @@ fun CIFileView(
|
|||||||
else
|
else
|
||||||
" "
|
" "
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
Column(
|
Column {
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
file.fileName,
|
file.fileName,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
formatBytes(file.fileSize) + metaReserve,
|
formatBytes(file.fileSize) + metaReserve,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
maxLines = 1
|
maxLines = 1
|
||||||
)
|
)
|
||||||
@@ -174,14 +208,14 @@ fun CIFileView(
|
|||||||
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
||||||
private val sentFile = ChatItem(
|
private val sentFile = ChatItem(
|
||||||
chatDir = CIDirection.DirectSnd(),
|
chatDir = CIDirection.DirectSnd(),
|
||||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemDeleted = false, itemEdited = true, editable = false),
|
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.SndSent(), itemEdited = true),
|
||||||
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
content = CIContent.SndMsgContent(msgContent = MsgContent.MCFile("")),
|
||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
file = CIFile.getSample(fileStatus = CIFileStatus.SndComplete)
|
||||||
)
|
)
|
||||||
private val fileChatItemWtFile = ChatItem(
|
private val fileChatItemWtFile = ChatItem(
|
||||||
chatDir = CIDirection.DirectRcv(),
|
chatDir = CIDirection.DirectRcv(),
|
||||||
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false),
|
meta = CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead(), ),
|
||||||
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
content = CIContent.RcvMsgContent(msgContent = MsgContent.MCFile("")),
|
||||||
quotedItem = null,
|
quotedItem = null,
|
||||||
file = null
|
file = null
|
||||||
@@ -191,7 +225,7 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
ChatItem.getFileMsgContentSample(),
|
ChatItem.getFileMsgContentSample(),
|
||||||
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(fileName = "some_long_file_name_here", fileStatus = CIFileStatus.RcvInvitation),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvAccepted),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10)),
|
||||||
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
ChatItem.getFileMsgContentSample(fileStatus = CIFileStatus.RcvCancelled),
|
||||||
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(fileSize = 1_000_000_000, fileStatus = CIFileStatus.RcvInvitation),
|
||||||
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
ChatItem.getFileMsgContentSample(text = "Hello there", fileStatus = CIFileStatus.RcvInvitation),
|
||||||
@@ -205,6 +239,6 @@ class ChatItemProvider: PreviewParameterProvider<ChatItem> {
|
|||||||
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
fun PreviewCIFileFramedItemView(@PreviewParameter(ChatItemProvider::class) chatItem: ChatItem) {
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
FramedItemView(ChatInfo.Direct.sampleData, chatItem, showMenu = showMenu, receiveFile = {})
|
FramedItemView(ChatInfo.Direct.sampleData, chatItem, linkMode = SimplexLinkMode.DESCRIPTION, showMenu = showMenu, receiveFile = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-7
@@ -1,13 +1,10 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.SupervisedUserCircle
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -46,7 +43,7 @@ fun CIGroupInvitationView(
|
|||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.padding(end = 2.dp)
|
.padding(end = 2.dp)
|
||||||
) {
|
) {
|
||||||
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = Icons.Filled.SupervisedUserCircle, color = iconColor)
|
ProfileImage(size = 60.dp, image = groupInvitation.groupProfile.image, icon = R.drawable.ic_supervised_user_circle_filled, color = iconColor)
|
||||||
Spacer(Modifier.padding(horizontal = 3.dp))
|
Spacer(Modifier.padding(horizontal = 3.dp))
|
||||||
Column(
|
Column(
|
||||||
Modifier.defaultMinSize(minHeight = 60.dp),
|
Modifier.defaultMinSize(minHeight = 60.dp),
|
||||||
@@ -71,12 +68,14 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
modifier = if (action) Modifier.clickable(onClick = {
|
modifier = if (action) Modifier.clickable(onClick = {
|
||||||
joinGroup(groupInvitation.groupId)
|
joinGroup(groupInvitation.groupId)
|
||||||
}) else Modifier,
|
}) else Modifier,
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = if (sent) SentColorLight else ReceivedColorLight,
|
color = if (sent) sentColor else receivedColor,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -89,7 +88,6 @@ fun CIGroupInvitationView(
|
|||||||
Modifier
|
Modifier
|
||||||
.defaultMinSize(minWidth = 220.dp)
|
.defaultMinSize(minWidth = 220.dp)
|
||||||
.padding(bottom = 4.dp),
|
.padding(bottom = 4.dp),
|
||||||
horizontalAlignment = Alignment.Start
|
|
||||||
) {
|
) {
|
||||||
groupInfoView()
|
groupInfoView()
|
||||||
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
Column(Modifier.padding(top = 2.dp, start = 5.dp)) {
|
||||||
@@ -108,7 +106,7 @@ fun CIGroupInvitationView(
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
ci.timestampText,
|
ci.timestampText,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package chat.simplex.app.views.chat.item
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.os.Build.VERSION.SDK_INT
|
import android.os.Build.VERSION.SDK_INT
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.CircularProgressIndicator
|
import androidx.compose.material.CircularProgressIndicator
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Check
|
|
||||||
import androidx.compose.material.icons.outlined.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.outlined.MoreHoriz
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -21,14 +18,14 @@ import androidx.compose.ui.graphics.painter.Painter
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.layoutId
|
import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.CIFile
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.model.CIFileStatus
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
@@ -45,6 +42,25 @@ fun CIImageView(
|
|||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long) -> Unit
|
receiveFile: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
|
@Composable
|
||||||
|
fun progressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(stringId),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun loadingIndicator() {
|
fun loadingIndicator() {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
@@ -55,39 +71,20 @@ fun CIImageView(
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
when (file.fileStatus) {
|
when (file.fileStatus) {
|
||||||
CIFileStatus.SndTransfer ->
|
is CIFileStatus.SndStored ->
|
||||||
CircularProgressIndicator(
|
when (file.fileProtocol) {
|
||||||
Modifier.size(16.dp),
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
color = Color.White,
|
FileProtocol.SMP -> {}
|
||||||
strokeWidth = 2.dp
|
}
|
||||||
)
|
is CIFileStatus.SndTransfer -> progressIndicator()
|
||||||
CIFileStatus.SndComplete ->
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_image_snd_complete)
|
||||||
Icon(
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
Icons.Filled.Check,
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
stringResource(R.string.icon_descr_image_snd_complete),
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_asked_to_receive)
|
||||||
Modifier.fillMaxSize(),
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_image)
|
||||||
tint = Color.White
|
is CIFileStatus.RcvTransfer -> progressIndicator()
|
||||||
)
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
CIFileStatus.RcvAccepted ->
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MoreHoriz,
|
|
||||||
stringResource(R.string.icon_descr_waiting_for_image),
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
CIFileStatus.RcvTransfer ->
|
|
||||||
CircularProgressIndicator(
|
|
||||||
Modifier.size(16.dp),
|
|
||||||
color = Color.White,
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
CIFileStatus.RcvInvitation ->
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.ArrowDownward,
|
|
||||||
stringResource(R.string.icon_descr_asked_to_receive),
|
|
||||||
Modifier.fillMaxSize(),
|
|
||||||
tint = Color.White
|
|
||||||
)
|
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,7 +133,7 @@ fun CIImageView(
|
|||||||
|
|
||||||
fun fileSizeValid(): Boolean {
|
fun fileSizeValid(): Boolean {
|
||||||
if (file != null) {
|
if (file != null) {
|
||||||
return file.fileSize <= MAX_FILE_SIZE
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -179,15 +176,23 @@ fun CIImageView(
|
|||||||
} else {
|
} else {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
generalGetString(R.string.large_file),
|
generalGetString(R.string.large_file),
|
||||||
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(MAX_FILE_SIZE))
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CIFileStatus.RcvAccepted ->
|
CIFileStatus.RcvAccepted ->
|
||||||
AlertManager.shared.showAlertMsg(
|
when (file.fileProtocol) {
|
||||||
generalGetString(R.string.waiting_for_image),
|
FileProtocol.XFTP ->
|
||||||
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
AlertManager.shared.showAlertMsg(
|
||||||
)
|
generalGetString(R.string.waiting_for_image),
|
||||||
CIFileStatus.RcvTransfer -> {} // ?
|
generalGetString(R.string.image_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_image),
|
||||||
|
generalGetString(R.string.image_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
CIFileStatus.RcvComplete -> {} // ?
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
CIFileStatus.RcvCancelled -> {} // TODO
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
else -> {}
|
else -> {}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import SectionSpacer
|
||||||
|
import SectionView
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.ui.theme.DEFAULT_PADDING
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItem
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIInvalidJSONView(json: String) {
|
||||||
|
Row(Modifier
|
||||||
|
.clickable { ModalManager.shared.showModal(true) { InvalidJSONView(json) } }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.invalid_data), color = Color.Red, fontStyle = FontStyle.Italic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun InvalidJSONView(json: String) {
|
||||||
|
Column {
|
||||||
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
|
SectionView {
|
||||||
|
val context = LocalContext.current
|
||||||
|
SettingsActionItem(painterResource(R.drawable.ic_share), generalGetString(R.string.share_verb), click = {
|
||||||
|
shareText(context, json)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(DEFAULT_PADDING).fillMaxWidth().verticalScroll(rememberScrollState())) {
|
||||||
|
Text(json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,62 +2,85 @@ package chat.simplex.app.views.chat.item
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import chat.simplex.app.R
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIMetaView(chatItem: ChatItem, metaColor: Color = HighOrLowlight) {
|
fun CIMetaView(chatItem: ChatItem, timedMessagesTTL: Int?, metaColor: Color = MaterialTheme.colors.secondary) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (!chatItem.isDeletedContent) {
|
if (chatItem.isDeletedContent) {
|
||||||
if (chatItem.meta.itemEdited) {
|
Text(
|
||||||
Icon(
|
chatItem.timestampText,
|
||||||
Icons.Filled.Edit,
|
color = metaColor,
|
||||||
modifier = Modifier.height(12.dp).padding(end = 1.dp),
|
fontSize = 14.sp,
|
||||||
contentDescription = stringResource(R.string.icon_descr_edited),
|
modifier = Modifier.padding(start = 3.dp)
|
||||||
tint = metaColor,
|
)
|
||||||
)
|
} else {
|
||||||
}
|
CIMetaText(chatItem.meta, timedMessagesTTL, metaColor)
|
||||||
CIStatusView(chatItem.meta.itemStatus, metaColor)
|
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
chatItem.timestampText,
|
|
||||||
color = metaColor,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier = Modifier.padding(start = 3.dp)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
// changing this function requires updating reserveSpaceForMeta
|
||||||
|
private fun CIMetaText(meta: CIMeta, chatTTL: Int?, color: Color) {
|
||||||
|
if (meta.itemEdited) {
|
||||||
|
StatusIconText(painterResource(R.drawable.ic_edit), color)
|
||||||
|
Spacer(Modifier.width(3.dp))
|
||||||
|
}
|
||||||
|
if (meta.disappearing) {
|
||||||
|
StatusIconText(painterResource(R.drawable.ic_timer), color)
|
||||||
|
val ttl = meta.itemTimed?.ttl
|
||||||
|
if (ttl != chatTTL) {
|
||||||
|
Text(TimedMessagesPreference.shortTtlText(ttl), color = color, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color)
|
||||||
|
if (statusIcon != null) {
|
||||||
|
val (icon, statusColor) = statusIcon
|
||||||
|
StatusIconText(painterResource(icon), statusColor)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
} else if (!meta.disappearing) {
|
||||||
|
StatusIconText(painterResource(R.drawable.ic_circle_filled), Color.Transparent)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Text(meta.timestampText, color = color, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the conditions in this function should match CIMetaText
|
||||||
|
fun reserveSpaceForMeta(meta: CIMeta, chatTTL: Int?): String {
|
||||||
|
val iconSpace = " "
|
||||||
|
var res = ""
|
||||||
|
if (meta.itemEdited) res += iconSpace
|
||||||
|
if (meta.itemTimed != null) {
|
||||||
|
res += iconSpace
|
||||||
|
val ttl = meta.itemTimed.ttl
|
||||||
|
if (ttl != chatTTL) {
|
||||||
|
res += TimedMessagesPreference.shortTtlText(ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meta.statusIcon(CurrentColors.value.colors.secondary) != null || !meta.disappearing) {
|
||||||
|
res += iconSpace
|
||||||
|
}
|
||||||
|
return res + meta.timestampText
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CIStatusView(status: CIStatus, metaColor: Color = HighOrLowlight) {
|
private fun StatusIconText(icon: Painter, color: Color) {
|
||||||
when (status) {
|
Icon(icon, null, Modifier.height(12.dp), tint = color)
|
||||||
is CIStatus.SndSent -> {
|
|
||||||
Icon(Icons.Filled.Check, stringResource(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = metaColor)
|
|
||||||
}
|
|
||||||
is CIStatus.SndErrorAuth -> {
|
|
||||||
Icon(Icons.Filled.Close, stringResource(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red)
|
|
||||||
}
|
|
||||||
is CIStatus.SndError -> {
|
|
||||||
Icon(Icons.Filled.WarningAmber, stringResource(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow)
|
|
||||||
}
|
|
||||||
is CIStatus.RcvNew -> {
|
|
||||||
Icon(Icons.Filled.Circle, stringResource(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = MaterialTheme.colors.primary)
|
|
||||||
}
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@@ -66,7 +89,8 @@ fun PreviewCIMetaView() {
|
|||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +101,8 @@ fun PreviewCIMetaViewUnread() {
|
|||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
status = CIStatus.RcvNew()
|
status = CIStatus.RcvNew()
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,8 +112,9 @@ fun PreviewCIMetaViewSendFailed() {
|
|||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX()))
|
status = CIStatus.SndError("CMD SYNTAX")
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +124,8 @@ fun PreviewCIMetaViewSendNoAuth() {
|
|||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +135,8 @@ fun PreviewCIMetaViewSendSent() {
|
|||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent()
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +147,8 @@ fun PreviewCIMetaViewEdited() {
|
|||||||
chatItem = ChatItem.getSampleData(
|
chatItem = ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
itemEdited = true
|
itemEdited = true
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +160,8 @@ fun PreviewCIMetaViewEditedUnread() {
|
|||||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
1, CIDirection.DirectRcv(), Clock.System.now(), "hello",
|
||||||
itemEdited = true,
|
itemEdited = true,
|
||||||
status=CIStatus.RcvNew()
|
status=CIStatus.RcvNew()
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +173,8 @@ fun PreviewCIMetaViewEditedSent() {
|
|||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello",
|
||||||
itemEdited = true,
|
itemEdited = true,
|
||||||
status=CIStatus.SndSent()
|
status=CIStatus.SndSent()
|
||||||
)
|
),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +182,7 @@ fun PreviewCIMetaViewEditedSent() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreviewCIMetaViewDeletedContent() {
|
fun PreviewCIMetaViewDeletedContent() {
|
||||||
CIMetaView(
|
CIMetaView(
|
||||||
chatItem = ChatItem.getDeletedContentSampleData()
|
chatItem = ChatItem.getDeletedContentSampleData(),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIRcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean) {
|
||||||
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.decryption_error),
|
||||||
|
text = when (msgDecryptError) {
|
||||||
|
MsgDecryptError.RatchetHeader -> String.format(generalGetString(R.string.alert_text_decryption_error_header), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
MsgDecryptError.TooManySkipped -> String.format(generalGetString(R.string.alert_text_decryption_error_too_many_skipped), msgCount.toLong()) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_permanent_error_reconnect)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Rect
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.layout.*
|
||||||
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.graphics.drawable.toDrawable
|
||||||
|
import chat.simplex.app.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIVideoView(
|
||||||
|
image: String,
|
||||||
|
duration: Int,
|
||||||
|
file: CIFile?,
|
||||||
|
imageProvider: () -> ImageGalleryProvider,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
receiveFile: (Long) -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID),
|
||||||
|
contentAlignment = Alignment.TopEnd
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) }
|
||||||
|
val preview = remember(image) { base64ToBitmap(image) }
|
||||||
|
if (file != null && filePath != null) {
|
||||||
|
val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) }
|
||||||
|
val view = LocalView.current
|
||||||
|
VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = {
|
||||||
|
hideKeyboard(view)
|
||||||
|
ModalManager.shared.showCustomModal(animated = false) { close ->
|
||||||
|
ImageFullScreenView(imageProvider, close)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Box {
|
||||||
|
ImageView(preview, showMenu, onClick = {
|
||||||
|
if (file != null) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
CIFileStatus.RcvInvitation ->
|
||||||
|
receiveFileIfValidSize(file, receiveFile)
|
||||||
|
CIFileStatus.RcvAccepted ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_video),
|
||||||
|
generalGetString(R.string.video_will_be_received_when_contact_completes_uploading)
|
||||||
|
)
|
||||||
|
|
||||||
|
FileProtocol.SMP ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.waiting_for_video),
|
||||||
|
generalGetString(R.string.video_will_be_received_when_contact_is_online)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ?
|
||||||
|
CIFileStatus.RcvComplete -> {} // ?
|
||||||
|
CIFileStatus.RcvCancelled -> {} // TODO
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (file != null) {
|
||||||
|
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
|
||||||
|
}
|
||||||
|
if (file?.fileStatus is CIFileStatus.RcvInvitation) {
|
||||||
|
PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadingIndicator(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) }
|
||||||
|
val videoPlaying = remember(uri.path) { player.videoPlaying }
|
||||||
|
val progress = remember(uri.path) { player.progress }
|
||||||
|
val duration = remember(uri.path) { player.duration }
|
||||||
|
val preview by remember { player.preview }
|
||||||
|
// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled }
|
||||||
|
val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo }
|
||||||
|
val play = {
|
||||||
|
player.enableSound(true)
|
||||||
|
player.play(true)
|
||||||
|
}
|
||||||
|
val stop = {
|
||||||
|
player.enableSound(false)
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } }
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
val windowWidth = LocalWindowWidth()
|
||||||
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
StyledPlayerView(ctx).apply {
|
||||||
|
useController = false
|
||||||
|
resizeMode = RESIZE_MODE_FIXED_WIDTH
|
||||||
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Modifier
|
||||||
|
.width(width)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = { if (player.player.playWhenReady) stop() else onClick() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (showPreview.value) {
|
||||||
|
ImageView(preview, showMenu, onClick)
|
||||||
|
PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play)
|
||||||
|
}
|
||||||
|
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
Modifier.align(Alignment.Center),
|
||||||
|
color = Color.Black.copy(alpha = 0.25f),
|
||||||
|
shape = RoundedCornerShape(percent = 50)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 40.dp, minHeight = 40.dp)
|
||||||
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (error) WarningOrange else Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DurationProgress(file: CIFile, playing: MutableState<Boolean>, duration: MutableState<Long>, progress: MutableState<Long>/*, soundEnabled: MutableState<Boolean>*/) {
|
||||||
|
if (duration.value > 0L || progress.value > 0) {
|
||||||
|
Row {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(DEFAULT_PADDING_HALF)
|
||||||
|
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||||
|
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
val time = if (progress.value > 0) progress.value else duration.value
|
||||||
|
val timeStr = durationText((time / 1000).toInt())
|
||||||
|
val width = if (timeStr.length <= 5) 44 else 50
|
||||||
|
Text(
|
||||||
|
timeStr,
|
||||||
|
Modifier.widthIn(min = with(LocalDensity.current) { width.sp.toDp() }).padding(horizontal = 4.dp),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
/*if (!soundEnabled.value) {
|
||||||
|
Icon(painterResource(R.drawable.ic_volume_off_filled), null,
|
||||||
|
Modifier.padding(start = 5.dp).size(10.dp),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}*/
|
||||||
|
}
|
||||||
|
if (!playing.value) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(top = DEFAULT_PADDING_HALF)
|
||||||
|
.background(Color.Black.copy(alpha = 0.35f), RoundedCornerShape(percent = 50))
|
||||||
|
.padding(vertical = 2.dp, horizontal = 4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
formatBytes(file.fileSize),
|
||||||
|
Modifier.padding(horizontal = 4.dp),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImageView(preview: Bitmap, showMenu: MutableState<Boolean>, onClick: () -> Unit) {
|
||||||
|
val windowWidth = LocalWindowWidth()
|
||||||
|
val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp }
|
||||||
|
Image(
|
||||||
|
preview.asImageBitmap(),
|
||||||
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
|
modifier = Modifier
|
||||||
|
.width(width)
|
||||||
|
.combinedClickable(
|
||||||
|
onLongClick = { showMenu.value = true },
|
||||||
|
onClick = onClick
|
||||||
|
),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LocalWindowWidth(): Dp {
|
||||||
|
val view = LocalView.current
|
||||||
|
val density = LocalDensity.current.density
|
||||||
|
return remember {
|
||||||
|
val rect = Rect()
|
||||||
|
view.getWindowVisibleDisplayFrame(rect)
|
||||||
|
(rect.width() / density).dp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun progressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(16.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun fileIcon(icon: Painter, @StringRes stringId: Int) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
stringResource(stringId),
|
||||||
|
Modifier.fillMaxSize(),
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun progressCircle(progress: Long, total: Long) {
|
||||||
|
val angle = 360f * (progress.toDouble() / total.toDouble()).toFloat()
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 2.dp.toPx() }
|
||||||
|
val strokeColor = Color.White
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = Color.Transparent,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun loadingIndicator(file: CIFile?) {
|
||||||
|
if (file != null) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
.size(20.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
when (file.fileStatus) {
|
||||||
|
is CIFileStatus.SndStored ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressIndicator()
|
||||||
|
FileProtocol.SMP -> {}
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndTransfer ->
|
||||||
|
when (file.fileProtocol) {
|
||||||
|
FileProtocol.XFTP -> progressCircle(file.fileStatus.sndProgress, file.fileStatus.sndTotal)
|
||||||
|
FileProtocol.SMP -> progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.SndComplete -> fileIcon(painterResource(R.drawable.ic_check_filled), R.string.icon_descr_video_snd_complete)
|
||||||
|
is CIFileStatus.SndCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.SndError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(R.drawable.ic_arrow_downward), R.string.icon_descr_video_asked_to_receive)
|
||||||
|
is CIFileStatus.RcvAccepted -> fileIcon(painterResource(R.drawable.ic_more_horiz), R.string.icon_descr_waiting_for_video)
|
||||||
|
is CIFileStatus.RcvTransfer ->
|
||||||
|
if (file.fileProtocol == FileProtocol.XFTP && file.fileStatus.rcvProgress < file.fileStatus.rcvTotal) {
|
||||||
|
progressCircle(file.fileStatus.rcvProgress, file.fileStatus.rcvTotal)
|
||||||
|
} else {
|
||||||
|
progressIndicator()
|
||||||
|
}
|
||||||
|
is CIFileStatus.RcvCancelled -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
is CIFileStatus.RcvError -> fileIcon(painterResource(R.drawable.ic_close), R.string.icon_descr_file)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileSizeValid(file: CIFile?): Boolean {
|
||||||
|
if (file != null) {
|
||||||
|
return file.fileSize <= getMaxFileSize(file.fileProtocol)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
|
||||||
|
if (fileSizeValid(file)) {
|
||||||
|
receiveFile(file.fileId)
|
||||||
|
} else {
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
generalGetString(R.string.large_file),
|
||||||
|
String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun videoViewFullWidth(windowWidth: Dp): Dp {
|
||||||
|
val approximatePadding = 100.dp
|
||||||
|
return minOf(1000.dp, windowWidth - approximatePadding)
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
|
// TODO refactor https://github.com/simplex-chat/simplex-chat/pull/1451#discussion_r1033429901
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIVoiceView(
|
||||||
|
providedDurationSec: Int,
|
||||||
|
file: CIFile?,
|
||||||
|
edited: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
ci: ChatItem,
|
||||||
|
timedMessagesTTL: Int?,
|
||||||
|
longClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = 6.dp, end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (file != null) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val filePath = remember(file.filePath, file.fileStatus) { getLoadedFilePath(context, file) }
|
||||||
|
var brokenAudio by rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val audioPlaying = rememberSaveable(file.filePath) { mutableStateOf(false) }
|
||||||
|
val progress = rememberSaveable(file.filePath) { mutableStateOf(0) }
|
||||||
|
val duration = rememberSaveable(file.filePath) { mutableStateOf(providedDurationSec * 1000) }
|
||||||
|
val play = {
|
||||||
|
AudioPlayer.play(filePath, audioPlaying, progress, duration, true)
|
||||||
|
brokenAudio = !audioPlaying.value
|
||||||
|
}
|
||||||
|
val pause = {
|
||||||
|
AudioPlayer.pause(audioPlaying, progress)
|
||||||
|
}
|
||||||
|
val text = remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val time = when {
|
||||||
|
audioPlaying.value || progress.value != 0 -> progress.value
|
||||||
|
else -> duration.value
|
||||||
|
}
|
||||||
|
durationText(time / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick)
|
||||||
|
} else {
|
||||||
|
VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick)
|
||||||
|
val metaReserve = if (edited)
|
||||||
|
" "
|
||||||
|
else
|
||||||
|
" "
|
||||||
|
Text(metaReserve)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceLayout(
|
||||||
|
file: CIFile,
|
||||||
|
ci: ChatItem,
|
||||||
|
text: State<String>,
|
||||||
|
audioPlaying: State<Boolean>,
|
||||||
|
progress: State<Int>,
|
||||||
|
duration: State<Int>,
|
||||||
|
brokenAudio: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
timedMessagesTTL: Int?,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
hasText -> {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
|
}
|
||||||
|
sent -> {
|
||||||
|
Row {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
DurationText(text, PaddingValues(end = 12.dp))
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Row {
|
||||||
|
Column {
|
||||||
|
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick)
|
||||||
|
Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) {
|
||||||
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DurationText(text, PaddingValues(start = 12.dp))
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DurationText(text: State<String>, padding: PaddingValues) {
|
||||||
|
val minWidth = with(LocalDensity.current) { 45.sp.toDp() }
|
||||||
|
Text(
|
||||||
|
text.value,
|
||||||
|
Modifier
|
||||||
|
.padding(padding)
|
||||||
|
.widthIn(min = minWidth),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayPauseButton(
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
angle: Float,
|
||||||
|
strokeWidth: Float,
|
||||||
|
strokeColor: Color,
|
||||||
|
enabled: Boolean,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
|
Surface(
|
||||||
|
Modifier.drawRingModifier(angle, strokeColor, strokeWidth),
|
||||||
|
color = if (sent) sentColor else receivedColor,
|
||||||
|
shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.defaultMinSize(minWidth = 56.dp, minHeight = 56.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { if (!audioPlaying) play() else pause() },
|
||||||
|
onLongClick = longClick
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = if (error) WarningOrange else if (!enabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VoiceMsgIndicator(
|
||||||
|
file: CIFile?,
|
||||||
|
audioPlaying: Boolean,
|
||||||
|
sent: Boolean,
|
||||||
|
hasText: Boolean,
|
||||||
|
progress: State<Int>?,
|
||||||
|
duration: State<Int>?,
|
||||||
|
error: Boolean,
|
||||||
|
play: () -> Unit,
|
||||||
|
pause: () -> Unit,
|
||||||
|
longClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() }
|
||||||
|
val strokeColor = MaterialTheme.colors.primary
|
||||||
|
if (file != null && file.loaded && progress != null && duration != null) {
|
||||||
|
val angle = 360f * (progress.value.toDouble() / duration.value).toFloat()
|
||||||
|
if (hasText) {
|
||||||
|
IconButton({ if (!audioPlaying) play() else pause() }, Modifier.size(56.dp).drawRingModifier(angle, strokeColor, strokeWidth)) {
|
||||||
|
Icon(
|
||||||
|
if (audioPlaying) painterResource(R.drawable.ic_pause_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
|
contentDescription = null,
|
||||||
|
Modifier.size(36.dp),
|
||||||
|
tint = MaterialTheme.colors.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file?.fileStatus is CIFileStatus.RcvInvitation
|
||||||
|
|| file?.fileStatus is CIFileStatus.RcvTransfer
|
||||||
|
|| file?.fileStatus is CIFileStatus.RcvAccepted
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, false, false, {}, {}, longClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Modifier.drawRingModifier(angle: Float, color: Color, strokeWidth: Float) = drawWithCache {
|
||||||
|
val brush = Brush.linearGradient(
|
||||||
|
0f to Color.Transparent,
|
||||||
|
0f to color,
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(strokeWidth, strokeWidth),
|
||||||
|
tileMode = TileMode.Clamp
|
||||||
|
)
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawArc(
|
||||||
|
brush = brush,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = angle,
|
||||||
|
useCenter = false,
|
||||||
|
topLeft = Offset(strokeWidth / 2, strokeWidth / 2),
|
||||||
|
size = Size(size.width - strokeWidth, size.height - strokeWidth),
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Square)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier.size(32.dp),
|
||||||
|
color = if (isInDarkTheme()) FileDark else FileLight,
|
||||||
|
strokeWidth = 4.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,55 +1,63 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.*
|
import android.Manifest
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Edit
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.ComposeContextItem
|
import chat.simplex.app.views.chat.ComposeContextItem
|
||||||
import chat.simplex.app.views.chat.ComposeState
|
import chat.simplex.app.views.chat.ComposeState
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
|
||||||
|
// TODO refactor so that FramedItemView can show all CIContent items if they're deleted (see Swift code)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItemView(
|
fun ChatItemView(
|
||||||
user: User,
|
|
||||||
cInfo: ChatInfo,
|
cInfo: ChatInfo,
|
||||||
cItem: ChatItem,
|
cItem: ChatItem,
|
||||||
composeState: MutableState<ComposeState>,
|
composeState: MutableState<ComposeState>,
|
||||||
cxt: Context,
|
|
||||||
uriHandler: UriHandler? = null,
|
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
chatModelIncognito: Boolean,
|
|
||||||
useLinkPreviews: Boolean,
|
useLinkPreviews: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
joinGroup: (Long) -> Unit,
|
joinGroup: (Long) -> Unit,
|
||||||
acceptCall: (Contact) -> Unit,
|
acceptCall: (Contact) -> Unit,
|
||||||
scrollToItem: (Long) -> Unit,
|
scrollToItem: (Long) -> Unit,
|
||||||
|
acceptFeature: (Contact, ChatFeature, Int?) -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
val sent = cItem.chatDir.sent
|
val sent = cItem.chatDir.sent
|
||||||
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
|
||||||
val showMenu = remember { mutableStateOf(false) }
|
val showMenu = remember { mutableStateOf(false) }
|
||||||
|
val revealed = remember { mutableStateOf(false) }
|
||||||
|
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
|
||||||
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
val saveFileLauncher = rememberSaveFileLauncher(cxt = context, ciFile = cItem.file)
|
||||||
|
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||||
|
val live = composeState.value.liveMessage != null
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(bottom = 4.dp)
|
.padding(bottom = 4.dp)
|
||||||
@@ -62,7 +70,7 @@ fun ChatItemView(
|
|||||||
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
showMsgDeliveryErrorAlert(generalGetString(R.string.message_delivery_error_desc))
|
||||||
}
|
}
|
||||||
is CIStatus.SndError -> {
|
is CIStatus.SndError -> {
|
||||||
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError.string}")
|
showMsgDeliveryErrorAlert(generalGetString(R.string.unknown_error) + ": ${cItem.meta.itemStatus.agentError}")
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -70,87 +78,147 @@ fun ChatItemView(
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick)
|
.combinedClickable(onLongClick = { showMenu.value = true }, onClick = onClick),
|
||||||
) {
|
) {
|
||||||
@Composable fun ContentItem() {
|
@Composable
|
||||||
if (cItem.file == null && cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
|
fun framedItemView() {
|
||||||
EmojiItemView(cItem)
|
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, linkMode = linkMode, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessageQuestionText(): String {
|
||||||
|
return if (fullDeleteAllowed) {
|
||||||
|
generalGetString(R.string.delete_message_cannot_be_undone_warning)
|
||||||
} else {
|
} else {
|
||||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
generalGetString(R.string.delete_message_mark_deleted_warning)
|
||||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, showMember = showMember, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
|
||||||
}
|
}
|
||||||
DropdownMenu(
|
}
|
||||||
expanded = showMenu.value,
|
|
||||||
onDismissRequest = { showMenu.value = false },
|
fun moderateMessageQuestionText(): String {
|
||||||
Modifier.width(220.dp)
|
return if (fullDeleteAllowed) {
|
||||||
) {
|
generalGetString(R.string.moderate_message_will_be_deleted_warning)
|
||||||
ItemAction(stringResource(R.string.reply_verb), Icons.Outlined.Reply, onClick = {
|
} else {
|
||||||
if (composeState.value.editing) {
|
generalGetString(R.string.moderate_message_will_be_marked_warning)
|
||||||
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
}
|
||||||
} else {
|
}
|
||||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
|
||||||
}
|
@Composable
|
||||||
showMenu.value = false
|
fun MsgContentItemDropdownMenu() {
|
||||||
})
|
DefaultDropdownMenu(showMenu) {
|
||||||
ItemAction(stringResource(R.string.share_verb), Icons.Outlined.Share, onClick = {
|
if (cItem.meta.itemDeleted == null && !live) {
|
||||||
|
ItemAction(stringResource(R.string.reply_verb), painterResource(R.drawable.ic_reply), onClick = {
|
||||||
|
if (composeState.value.editing) {
|
||||||
|
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
|
||||||
|
} else {
|
||||||
|
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||||
|
}
|
||||||
|
showMenu.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ItemAction(stringResource(R.string.share_verb), painterResource(R.drawable.ic_share), onClick = {
|
||||||
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
val filePath = getLoadedFilePath(SimplexApp.context, cItem.file)
|
||||||
when {
|
when {
|
||||||
filePath != null -> shareFile(cxt, cItem.text, filePath)
|
filePath != null -> shareFile(context, cItem.text, filePath)
|
||||||
else -> shareText(cxt, cItem.content.text)
|
else -> shareText(context, cItem.content.text)
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
ItemAction(stringResource(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = {
|
ItemAction(stringResource(R.string.copy_verb), painterResource(R.drawable.ic_content_copy), onClick = {
|
||||||
copyText(cxt, cItem.content.text)
|
copyText(context, cItem.content.text)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile) {
|
if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) {
|
||||||
val filePath = getLoadedFilePath(context, cItem.file)
|
val filePath = getLoadedFilePath(context, cItem.file)
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
ItemAction(stringResource(R.string.save_verb), Icons.Outlined.SaveAlt, onClick = {
|
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||||
|
ItemAction(stringResource(R.string.save_verb), painterResource(R.drawable.ic_download), onClick = {
|
||||||
when (cItem.content.msgContent) {
|
when (cItem.content.msgContent) {
|
||||||
is MsgContent.MCImage -> saveImage(context, cItem.file)
|
is MsgContent.MCImage -> {
|
||||||
is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.hasPermission) {
|
||||||
|
saveImage(context, cItem.file)
|
||||||
|
} else {
|
||||||
|
writePermissionState.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cItem.meta.editable) {
|
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
|
||||||
ItemAction(stringResource(R.string.edit_verb), Icons.Filled.Edit, onClick = {
|
ItemAction(stringResource(R.string.edit_verb), painterResource(R.drawable.ic_edit_filled), onClick = {
|
||||||
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ItemAction(
|
if (cItem.meta.itemDeleted != null && revealed.value) {
|
||||||
stringResource(R.string.delete_verb),
|
ItemAction(
|
||||||
Icons.Outlined.Delete,
|
stringResource(R.string.hide_verb),
|
||||||
onClick = {
|
painterResource(R.drawable.ic_visibility_off),
|
||||||
showMenu.value = false
|
onClick = {
|
||||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
revealed.value = false
|
||||||
},
|
showMenu.value = false
|
||||||
color = Color.Red
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
|
||||||
|
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||||
|
}
|
||||||
|
if (!(live && cItem.meta.isLive)) {
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||||
|
if (groupInfo != null) {
|
||||||
|
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemDropdownMenu() {
|
||||||
|
DefaultDropdownMenu(showMenu) {
|
||||||
|
if (!cItem.isDeletedContent) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.reveal_verb),
|
||||||
|
painterResource(R.drawable.ic_visibility),
|
||||||
|
onClick = {
|
||||||
|
revealed.value = true
|
||||||
|
showMenu.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContentItem() {
|
||||||
|
val mc = cItem.content.msgContent
|
||||||
|
if (cItem.meta.itemDeleted != null && !revealed.value) {
|
||||||
|
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
MarkedDeletedItemDropdownMenu()
|
||||||
|
} else if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||||
|
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||||
|
EmojiItemView(cItem, cInfo.timedMessagesTTL)
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||||
|
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") })
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
framedItemView()
|
||||||
|
MsgContentItemDropdownMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable fun DeletedItem() {
|
@Composable fun DeletedItem() {
|
||||||
DeletedItemView(cItem, showMember = showMember)
|
DeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
DropdownMenu(
|
DefaultDropdownMenu(showMenu) {
|
||||||
expanded = showMenu.value,
|
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
|
||||||
onDismissRequest = { showMenu.value = false },
|
|
||||||
Modifier.width(220.dp)
|
|
||||||
) {
|
|
||||||
ItemAction(
|
|
||||||
stringResource(R.string.delete_verb),
|
|
||||||
Icons.Outlined.Delete,
|
|
||||||
onClick = {
|
|
||||||
showMenu.value = false
|
|
||||||
deleteMessageAlertDialog(cItem, deleteMessage = deleteMessage)
|
|
||||||
},
|
|
||||||
color = Color.Red
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,62 +233,178 @@ fun ChatItemView(
|
|||||||
is CIContent.RcvDeleted -> DeletedItem()
|
is CIContent.RcvDeleted -> DeletedItem()
|
||||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember)
|
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||||
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
is CIContent.RcvGroupEventContent -> CIEventView(cItem)
|
||||||
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
is CIContent.SndGroupEventContent -> CIEventView(cItem)
|
||||||
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
is CIContent.RcvConnEventContent -> CIEventView(cItem)
|
||||||
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
is CIContent.SndConnEventContent -> CIEventView(cItem)
|
||||||
|
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
|
||||||
|
is CIContent.RcvChatPreference -> {
|
||||||
|
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
|
||||||
|
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
|
||||||
|
}
|
||||||
|
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
|
||||||
|
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
|
||||||
|
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
|
||||||
|
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
|
||||||
|
is CIContent.SndModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.RcvModerated -> MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, showMember = showMember)
|
||||||
|
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = MaterialTheme.colors.onBackground) {
|
fun CancelFileItemAction(
|
||||||
DropdownMenuItem(onClick) {
|
fileId: Long,
|
||||||
Row {
|
showMenu: MutableState<Boolean>,
|
||||||
|
cancelFile: (Long) -> Unit,
|
||||||
|
cancelAction: CancelAction
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(cancelAction.uiActionId),
|
||||||
|
painterResource(R.drawable.ic_close),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
cancelFileAlertDialog(fileId, cancelFile = cancelFile, cancelAction = cancelAction)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteItemAction(
|
||||||
|
cItem: ChatItem,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
questionText: String,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.delete_verb),
|
||||||
|
painterResource(R.drawable.ic_delete),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ModerateItemAction(
|
||||||
|
cItem: ChatItem,
|
||||||
|
questionText: String,
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
deleteMessage: (Long, CIDeleteMode) -> Unit
|
||||||
|
) {
|
||||||
|
ItemAction(
|
||||||
|
stringResource(R.string.moderate_verb),
|
||||||
|
painterResource(R.drawable.ic_flag),
|
||||||
|
onClick = {
|
||||||
|
showMenu.value = false
|
||||||
|
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||||
|
},
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1F)
|
.weight(1F)
|
||||||
.padding(end = 15.dp),
|
.padding(end = 15.dp),
|
||||||
color = color
|
color = finalColor
|
||||||
)
|
)
|
||||||
Icon(icon, text, tint = color)
|
Icon(icon, text, tint = finalColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
@Composable
|
||||||
|
fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, color: Color = Color.Unspecified) {
|
||||||
|
val finalColor = if (color == Color.Unspecified) {
|
||||||
|
if (isInDarkTheme()) MenuTextColorDark else Color.Black
|
||||||
|
} else color
|
||||||
|
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1F)
|
||||||
|
.padding(end = 15.dp),
|
||||||
|
color = finalColor
|
||||||
|
)
|
||||||
|
Icon(icon, text, tint = finalColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction: CancelAction) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(cancelAction.alert.titleId),
|
||||||
|
text = generalGetString(cancelAction.alert.messageId),
|
||||||
|
confirmText = generalGetString(cancelAction.alert.confirmId),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
cancelFile(fileId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
AlertManager.shared.showAlertDialogButtons(
|
AlertManager.shared.showAlertDialogButtons(
|
||||||
title = generalGetString(R.string.delete_message__question),
|
title = generalGetString(R.string.delete_message__question),
|
||||||
text = generalGetString(R.string.delete_message_cannot_be_undone_warning),
|
text = questionText,
|
||||||
buttons = {
|
buttons = {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
}) { Text(stringResource(R.string.for_me_only)) }
|
}) { Text(stringResource(R.string.for_me_only), color = MaterialTheme.colors.error) }
|
||||||
if (chatItem.meta.editable) {
|
if (chatItem.meta.editable) {
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
}) { Text(stringResource(R.string.for_everybody)) }
|
}) { Text(stringResource(R.string.for_everybody), color = MaterialTheme.colors.error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||||
|
AlertManager.shared.showAlertDialog(
|
||||||
|
title = generalGetString(R.string.delete_member_message__question),
|
||||||
|
text = questionText,
|
||||||
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
|
destructive = true,
|
||||||
|
onConfirm = {
|
||||||
|
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMsgDeliveryErrorAlert(description: String) {
|
private fun showMsgDeliveryErrorAlert(description: String) {
|
||||||
AlertManager.shared.showAlertMsg(
|
AlertManager.shared.showAlertMsg(
|
||||||
title = generalGetString(R.string.message_delivery_error_title),
|
title = generalGetString(R.string.message_delivery_error_title),
|
||||||
@@ -233,20 +417,20 @@ private fun showMsgDeliveryErrorAlert(description: String) {
|
|||||||
fun PreviewChatItemView() {
|
fun PreviewChatItemView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||||
),
|
),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
scrollToItem = {},
|
scrollToItem = {},
|
||||||
|
acceptFeature = { _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,18 +440,18 @@ fun PreviewChatItemView() {
|
|||||||
fun PreviewChatItemViewDeletedContent() {
|
fun PreviewChatItemViewDeletedContent() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatItemView(
|
ChatItemView(
|
||||||
User.sampleData,
|
|
||||||
ChatInfo.Direct.sampleData,
|
ChatInfo.Direct.sampleData,
|
||||||
ChatItem.getDeletedContentSampleData(),
|
ChatItem.getDeletedContentSampleData(),
|
||||||
useLinkPreviews = true,
|
useLinkPreviews = true,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
|
||||||
cxt = LocalContext.current,
|
|
||||||
chatModelIncognito = false,
|
|
||||||
deleteMessage = { _, _ -> },
|
deleteMessage = { _, _ -> },
|
||||||
receiveFile = {},
|
receiveFile = {},
|
||||||
|
cancelFile = {},
|
||||||
joinGroup = {},
|
joinGroup = {},
|
||||||
acceptCall = { _ -> },
|
acceptCall = { _ -> },
|
||||||
scrollToItem = {},
|
scrollToItem = {},
|
||||||
|
acceptFeature = { _, _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
@@ -14,14 +14,16 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
val sent = ci.chatDir.sent
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = ReceivedColorLight,
|
color = if (sent) sentColor else receivedColor,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
@@ -30,12 +32,12 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
|||||||
Text(
|
Text(
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
appendSender(this, if (showMember) ci.memberDisplayName else null, true)
|
||||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = HighOrLowlight)) { append(ci.content.text) }
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) }
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
)
|
)
|
||||||
CIMetaView(ci)
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +51,8 @@ fun DeletedItemView(ci: ChatItem, showMember: Boolean = false) {
|
|||||||
fun PreviewDeletedItemView() {
|
fun PreviewDeletedItemView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
DeletedItemView(
|
DeletedItemView(
|
||||||
ChatItem.getDeletedContentSampleData()
|
ChatItem.getDeletedContentSampleData(),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
|
|||||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EmojiItemView(chatItem: ChatItem) {
|
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
EmojiText(chatItem.content.text)
|
EmojiText(chatItem.content.text)
|
||||||
CIMetaView(chatItem)
|
CIMetaView(chatItem, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+139
-33
@@ -4,8 +4,6 @@ import androidx.compose.foundation.*
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.InsertDriveFile
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -13,24 +11,23 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.layout.*
|
import androidx.compose.ui.layout.*
|
||||||
import androidx.compose.ui.platform.UriHandler
|
import androidx.compose.ui.platform.UriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.*
|
import androidx.compose.ui.tooling.preview.*
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.util.fastMap
|
import androidx.compose.ui.util.fastMap
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ChatItemLinkView
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.helpers.base64ToBitmap
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlin.math.min
|
||||||
val SentColorLight = Color(0x1E45B8FF)
|
|
||||||
val ReceivedColorLight = Color(0x20B1B0B5)
|
|
||||||
val SentQuoteColorLight = Color(0x2545B8FF)
|
|
||||||
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FramedItemView(
|
fun FramedItemView(
|
||||||
@@ -39,17 +36,22 @@ fun FramedItemView(
|
|||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
imageProvider: (() -> ImageGalleryProvider)? = null,
|
imageProvider: (() -> ImageGalleryProvider)? = null,
|
||||||
showMember: Boolean = false,
|
showMember: Boolean = false,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
showMenu: MutableState<Boolean>,
|
showMenu: MutableState<Boolean>,
|
||||||
receiveFile: (Long) -> Unit,
|
receiveFile: (Long) -> Unit,
|
||||||
onLinkLongClick: (link: String) -> Unit = {},
|
onLinkLongClick: (link: String) -> Unit = {},
|
||||||
scrollToItem: (Long) -> Unit = {},
|
scrollToItem: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val sent = ci.chatDir.sent
|
val sent = ci.chatDir.sent
|
||||||
|
val chatTTL = chatInfo.timedMessagesTTL
|
||||||
|
|
||||||
fun membership(): GroupMember? {
|
fun membership(): GroupMember? {
|
||||||
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Color.toQuote(): Color = if (isInDarkTheme()) lighter(0.12f) else darker(0.12f)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuotedMsgView(qi: CIQuote) {
|
fun ciQuotedMsgView(qi: CIQuote) {
|
||||||
Box(
|
Box(
|
||||||
@@ -58,16 +60,51 @@ fun FramedItemView(
|
|||||||
) {
|
) {
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
qi.text, qi.formattedText, sender = qi.sender(membership()), senderBold = true, maxLines = 3,
|
||||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
|
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||||
|
linkMode = linkMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (icon != null) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
caption,
|
||||||
|
Modifier.size(18.dp),
|
||||||
|
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) {
|
||||||
|
append(caption)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ciQuoteView(qi: CIQuote) {
|
fun ciQuoteView(qi: CIQuote) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
|
.background(if (sent) sentColor.toQuote() else receivedColor.toQuote())
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.combinedClickable(
|
.combinedClickable(
|
||||||
onLongClick = { showMenu.value = true },
|
onLongClick = { showMenu.value = true },
|
||||||
@@ -87,13 +124,25 @@ fun FramedItemView(
|
|||||||
modifier = Modifier.size(68.dp).clipToBounds()
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> {
|
is MsgContent.MCVideo -> {
|
||||||
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
ciQuotedMsgView(qi)
|
||||||
|
}
|
||||||
|
val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap()
|
||||||
|
Image(
|
||||||
|
imageBitmap,
|
||||||
|
contentDescription = stringResource(R.string.video_descr),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.size(68.dp).clipToBounds()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is MsgContent.MCFile, is MsgContent.MCVoice -> {
|
||||||
Box(Modifier.fillMaxWidth().weight(1f)) {
|
Box(Modifier.fillMaxWidth().weight(1f)) {
|
||||||
ciQuotedMsgView(qi)
|
ciQuotedMsgView(qi)
|
||||||
}
|
}
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.InsertDriveFile,
|
if (qi.content is MsgContent.MCFile) painterResource(R.drawable.ic_draft_filled) else painterResource(R.drawable.ic_mic_filled),
|
||||||
stringResource(R.string.icon_descr_file),
|
if (qi.content is MsgContent.MCFile) stringResource(R.string.icon_descr_file) else stringResource(R.string.voice_message),
|
||||||
Modifier
|
Modifier
|
||||||
.padding(top = 6.dp, end = 4.dp)
|
.padding(top = 6.dp, end = 4.dp)
|
||||||
.size(22.dp),
|
.size(22.dp),
|
||||||
@@ -108,27 +157,40 @@ fun FramedItemView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun ciFileView(ci: ChatItem, text: String) {
|
fun ciFileView(ci: ChatItem, text: String) {
|
||||||
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
CIFileView(ci.file, ci.meta.itemEdited, receiveFile)
|
||||||
if (text != "") {
|
if (text != "" || ci.meta.isLive) {
|
||||||
CIMarkdownText(ci, showMember, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode = linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val transparentBackground = ci.content.msgContent is MsgContent.MCImage && ci.content.text.isEmpty() && ci.quotedItem == null
|
val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) &&
|
||||||
|
!ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null
|
||||||
|
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Box(Modifier
|
Box(Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(
|
.background(
|
||||||
when {
|
when {
|
||||||
transparentBackground -> Color.Transparent
|
transparentBackground -> Color.Transparent
|
||||||
sent -> SentColorLight
|
sent -> sentColor
|
||||||
else -> ReceivedColorLight
|
else -> receivedColor
|
||||||
}
|
}
|
||||||
)) {
|
)) {
|
||||||
var metaColor = HighOrLowlight
|
var metaColor = MaterialTheme.colors.secondary
|
||||||
Box(contentAlignment = Alignment.BottomEnd) {
|
Box(contentAlignment = Alignment.BottomEnd) {
|
||||||
Column(Modifier.width(IntrinsicSize.Max)) {
|
Column(Modifier.width(IntrinsicSize.Max)) {
|
||||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||||
|
if (ci.meta.itemDeleted != null) {
|
||||||
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
|
FramedItemHeader(String.format(stringResource(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(R.drawable.ic_flag))
|
||||||
|
} else {
|
||||||
|
FramedItemHeader(stringResource(R.string.marked_deleted_description), true, painterResource(R.drawable.ic_delete))
|
||||||
|
}
|
||||||
|
} else if (ci.meta.isLive) {
|
||||||
|
FramedItemHeader(stringResource(R.string.live), false)
|
||||||
|
}
|
||||||
ci.quotedItem?.let { ciQuoteView(it) }
|
ci.quotedItem?.let { ciQuoteView(it) }
|
||||||
if (ci.file == null && ci.formattedText == null && isShortEmoji(ci.content.text)) {
|
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -144,30 +206,44 @@ fun FramedItemView(
|
|||||||
when (val mc = ci.content.msgContent) {
|
when (val mc = ci.content.msgContent) {
|
||||||
is MsgContent.MCImage -> {
|
is MsgContent.MCImage -> {
|
||||||
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||||
if (mc.text == "") {
|
if (mc.text == "" && !ci.meta.isLive) {
|
||||||
metaColor = Color.White
|
metaColor = Color.White
|
||||||
} else {
|
} else {
|
||||||
CIMarkdownText(ci, showMember, uriHandler)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCVideo -> {
|
||||||
|
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile)
|
||||||
|
if (mc.text == "" && !ci.meta.isLive) {
|
||||||
|
metaColor = Color.White
|
||||||
|
} else {
|
||||||
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MsgContent.MCVoice -> {
|
||||||
|
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") })
|
||||||
|
if (mc.text != "") {
|
||||||
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||||
is MsgContent.MCUnknown ->
|
is MsgContent.MCUnknown ->
|
||||||
if (ci.file == null) {
|
if (ci.file == null) {
|
||||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
} else {
|
} else {
|
||||||
ciFileView(ci, mc.text)
|
ciFileView(ci, mc.text)
|
||||||
}
|
}
|
||||||
is MsgContent.MCLink -> {
|
is MsgContent.MCLink -> {
|
||||||
ChatItemLinkView(mc.preview)
|
ChatItemLinkView(mc.preview)
|
||||||
CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
}
|
}
|
||||||
else -> CIMarkdownText(ci, showMember, uriHandler, onLinkLongClick)
|
else -> CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler, onLinkLongClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||||
CIMetaView(ci, metaColor)
|
CIMetaView(ci, chatTTL, metaColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,20 +252,29 @@ fun FramedItemView(
|
|||||||
@Composable
|
@Composable
|
||||||
fun CIMarkdownText(
|
fun CIMarkdownText(
|
||||||
ci: ChatItem,
|
ci: ChatItem,
|
||||||
|
chatTTL: Int?,
|
||||||
showMember: Boolean,
|
showMember: Boolean,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
uriHandler: UriHandler?,
|
uriHandler: UriHandler?,
|
||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||||
|
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
ci.content.text, ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
text, if (text.isEmpty()) emptyList() else ci.formattedText, if (showMember) ci.memberDisplayName else null,
|
||||||
metaText = ci.timestampText, edited = ci.meta.itemEdited,
|
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||||
|
/**
|
||||||
|
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
|
||||||
|
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
|
||||||
|
* See [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||||
|
* */
|
||||||
|
const val MAX_SAFE_WIDTH = 0x3FFFF - 1
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PriorityLayout(
|
fun PriorityLayout(
|
||||||
@@ -197,6 +282,17 @@ fun PriorityLayout(
|
|||||||
priorityLayoutId: String,
|
priorityLayoutId: String,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
|
/**
|
||||||
|
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||||
|
* */
|
||||||
|
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||||
|
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||||
|
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||||
|
width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
|
||||||
|
width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
|
||||||
|
else -> 0x1FFF // shouldn't happen since width is limited already
|
||||||
|
}
|
||||||
|
|
||||||
Layout(
|
Layout(
|
||||||
content = content,
|
content = content,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -208,9 +304,11 @@ fun PriorityLayout(
|
|||||||
if (it.layoutId == priorityLayoutId)
|
if (it.layoutId == priorityLayoutId)
|
||||||
imagePlaceable!!
|
imagePlaceable!!
|
||||||
else
|
else
|
||||||
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) }
|
it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: min(MAX_SAFE_WIDTH, constraints.maxWidth))) }
|
||||||
// Limit width for every other element to width of important element and height for a sum of all elements
|
// Limit width for every other element to width of important element and height for a sum of all elements.
|
||||||
layout(imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width }, placeables.sumOf { it.height }) {
|
val width = imagePlaceable?.measuredWidth ?: min(MAX_SAFE_WIDTH, placeables.maxOf { it.width })
|
||||||
|
val height = minOf(maxSafeHeight(width), placeables.sumOf { it.height })
|
||||||
|
layout(width, height) {
|
||||||
var y = 0
|
var y = 0
|
||||||
placeables.forEach {
|
placeables.forEach {
|
||||||
it.place(0, y)
|
it.place(0, y)
|
||||||
@@ -234,6 +332,7 @@ fun PreviewTextItemViewSnd(@PreviewParameter(EditedProvider::class) edited: Bool
|
|||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", itemEdited = edited,
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -250,6 +349,7 @@ fun PreviewTextItemViewRcv(@PreviewParameter(EditedProvider::class) edited: Bool
|
|||||||
ChatItem.getSampleData(
|
ChatItem.getSampleData(
|
||||||
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
1, CIDirection.DirectRcv(), Clock.System.now(), "hello", itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -270,6 +370,7 @@ fun PreviewTextItemViewLong(@PreviewParameter(EditedProvider::class) edited: Boo
|
|||||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.",
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -291,6 +392,7 @@ fun PreviewTextItemViewQuote(@PreviewParameter(EditedProvider::class) edited: Bo
|
|||||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv()),
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -312,6 +414,7 @@ fun PreviewTextItemViewEmoji(@PreviewParameter(EditedProvider::class) edited: Bo
|
|||||||
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv()),
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -340,6 +443,7 @@ fun PreviewQuoteWithTextAndImage(@PreviewParameter(EditedProvider::class) edited
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -368,6 +472,7 @@ fun PreviewQuoteWithLongTextAndImage(@PreviewParameter(EditedProvider::class) ed
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
@@ -395,6 +500,7 @@ fun PreviewQuoteWithLongTextAndFile(@PreviewParameter(EditedProvider::class) edi
|
|||||||
quotedItem = ciQuote,
|
quotedItem = ciQuote,
|
||||||
itemEdited = edited
|
itemEdited = edited
|
||||||
),
|
),
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||||
showMenu = showMenu,
|
showMenu = showMenu,
|
||||||
receiveFile = {}
|
receiveFile = {}
|
||||||
)
|
)
|
||||||
|
|||||||
+126
-59
@@ -3,21 +3,28 @@ package chat.simplex.app.views.chat.item
|
|||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.view.View
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
import androidx.compose.ui.input.pointer.*
|
import androidx.compose.ui.input.pointer.*
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.platform.LocalView
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.chat.ProviderMedia
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.compose.rememberAsyncImagePainter
|
import coil.compose.rememberAsyncImagePainter
|
||||||
@@ -26,13 +33,16 @@ import coil.decode.ImageDecoderDecoder
|
|||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import com.google.accompanist.pager.*
|
import com.google.accompanist.pager.*
|
||||||
|
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||||
|
import com.google.android.exoplayer2.ui.StyledPlayerView
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.math.absoluteValue
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
interface ImageGalleryProvider {
|
interface ImageGalleryProvider {
|
||||||
val initialIndex: Int
|
val initialIndex: Int
|
||||||
val totalImagesSize: MutableState<Int>
|
val totalMediaSize: MutableState<Int>
|
||||||
fun getImage(index: Int): Pair<Bitmap, Uri>?
|
fun getMedia(index: Int): ProviderMedia?
|
||||||
fun currentPageChanged(index: Int)
|
fun currentPageChanged(index: Int)
|
||||||
fun scrollToStart()
|
fun scrollToStart()
|
||||||
fun onDismiss(index: Int)
|
fun onDismiss(index: Int)
|
||||||
@@ -48,13 +58,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
|
||||||
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
// which makes this blank page visible for a moment. Prevent it by doing the check ourselves
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (provider.getImage(provider.initialIndex - 1) == null) {
|
if (provider.getMedia(provider.initialIndex - 1) == null) {
|
||||||
provider.scrollToStart()
|
provider.scrollToStart()
|
||||||
pagerState.scrollToPage(0)
|
pagerState.scrollToPage(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index ->
|
val playersToRelease = rememberSaveable { mutableSetOf<Uri>() }
|
||||||
|
DisposableEffectOnGone(
|
||||||
|
whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } }
|
||||||
|
)
|
||||||
|
HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index ->
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -74,20 +88,21 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
if (settledCurrentPage != provider.initialIndex)
|
if (settledCurrentPage != provider.initialIndex)
|
||||||
provider.currentPageChanged(index)
|
provider.currentPageChanged(index)
|
||||||
}
|
}
|
||||||
val image = provider.getImage(index)
|
val media = provider.getMedia(index)
|
||||||
if (image == null) {
|
if (media == null) {
|
||||||
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
// No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically
|
||||||
scope.launch {
|
SideEffect {
|
||||||
when (settledCurrentPage) {
|
scope.launch {
|
||||||
index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1
|
when (settledCurrentPage) {
|
||||||
index + 1 -> {
|
index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1
|
||||||
provider.scrollToStart()
|
index + 1 -> {
|
||||||
pagerState.scrollToPage(0)
|
provider.scrollToStart()
|
||||||
|
pagerState.scrollToPage(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val (imageBitmap: Bitmap, uri: Uri) = image
|
|
||||||
var scale by remember { mutableStateOf(1f) }
|
var scale by remember { mutableStateOf(1f) }
|
||||||
var translationX by remember { mutableStateOf(0f) }
|
var translationX by remember { mutableStateOf(0f) }
|
||||||
var translationY by remember { mutableStateOf(0f) }
|
var translationY by remember { mutableStateOf(0f) }
|
||||||
@@ -98,54 +113,106 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
|
|||||||
translationX = 0f
|
translationX = 0f
|
||||||
translationY = 0f
|
translationY = 0f
|
||||||
}
|
}
|
||||||
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
val modifier = Modifier
|
||||||
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
.onGloballyPositioned {
|
||||||
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
viewWidth = it.size.width
|
||||||
.components {
|
|
||||||
if (Build.VERSION.SDK_INT >= 28) {
|
|
||||||
add(ImageDecoderDecoder.Factory())
|
|
||||||
} else {
|
|
||||||
add(GifDecoder.Factory())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.build()
|
.graphicsLayer(
|
||||||
Image(
|
scaleX = scale,
|
||||||
rememberAsyncImagePainter(
|
scaleY = scale,
|
||||||
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
translationX = translationX,
|
||||||
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
translationY = translationY,
|
||||||
imageLoader = imageLoader
|
)
|
||||||
),
|
.pointerInput(Unit) {
|
||||||
contentDescription = stringResource(R.string.image_descr),
|
detectTransformGestures(
|
||||||
contentScale = ContentScale.Fit,
|
{ allowTranslate },
|
||||||
modifier = Modifier
|
onGesture = { _, pan, gestureZoom, _ ->
|
||||||
.onGloballyPositioned {
|
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
||||||
viewWidth = it.size.width
|
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
||||||
}
|
if (scale > 1 && allowTranslate) {
|
||||||
.graphicsLayer(
|
translationX += pan.x * scale
|
||||||
scaleX = scale,
|
translationY += pan.y * scale
|
||||||
scaleY = scale,
|
} else if (allowTranslate) {
|
||||||
translationX = translationX,
|
translationX = 0f
|
||||||
translationY = translationY,
|
translationY = 0f
|
||||||
)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
detectTransformGestures(
|
|
||||||
{ allowTranslate },
|
|
||||||
onGesture = { _, pan, gestureZoom, _ ->
|
|
||||||
scale = (scale * gestureZoom).coerceIn(1f, 20f)
|
|
||||||
allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0
|
|
||||||
if (scale > 1 && allowTranslate) {
|
|
||||||
translationX += pan.x * scale
|
|
||||||
translationY += pan.y * scale
|
|
||||||
} else if (allowTranslate) {
|
|
||||||
translationX = 0f
|
|
||||||
translationY = 0f
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.fillMaxSize()
|
||||||
|
if (media is ProviderMedia.Image) {
|
||||||
|
val (uri: Uri, imageBitmap: Bitmap) = media
|
||||||
|
// I'm making a new instance of imageLoader here because if I use one instance in multiple places
|
||||||
|
// after end of composition here a GIF from the first instance will be paused automatically which isn't what I want
|
||||||
|
val imageLoader = ImageLoader.Builder(LocalContext.current)
|
||||||
|
.components {
|
||||||
|
if (Build.VERSION.SDK_INT >= 28) {
|
||||||
|
add(ImageDecoderDecoder.Factory())
|
||||||
|
} else {
|
||||||
|
add(GifDecoder.Factory())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.fillMaxSize(),
|
.build()
|
||||||
)
|
Image(
|
||||||
|
rememberAsyncImagePainter(
|
||||||
|
ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(),
|
||||||
|
placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil
|
||||||
|
imageLoader = imageLoader
|
||||||
|
),
|
||||||
|
contentDescription = stringResource(R.string.image_descr),
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else if (media is ProviderMedia.Video) {
|
||||||
|
val preview = remember(media.uri.path) { base64ToBitmap(media.preview) }
|
||||||
|
VideoView(modifier, media.uri, preview, index == settledCurrentPage)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { playersToRelease.add(media.uri) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) }
|
||||||
|
val isCurrentPage = rememberUpdatedState(currentPage)
|
||||||
|
val play = {
|
||||||
|
player.play(true)
|
||||||
|
}
|
||||||
|
val stop = {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
player.enableSound(true)
|
||||||
|
snapshotFlow { isCurrentPage.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { if (it) play() else stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
StyledPlayerView(ctx).apply {
|
||||||
|
resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||||
|
} else {
|
||||||
|
AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||||
|
}
|
||||||
|
setShowPreviousButton(false)
|
||||||
|
setShowNextButton(false)
|
||||||
|
setShowSubtitleButton(false)
|
||||||
|
setShowVrButton(false)
|
||||||
|
controllerAutoShow = false
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.3f).toArgb())
|
||||||
|
findViewById<View>(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false
|
||||||
|
this.player = player.player
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+37
-10
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -17,21 +18,45 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.ChatItem
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.model.MsgErrorType
|
||||||
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.AlertManager
|
import chat.simplex.app.views.helpers.AlertManager
|
||||||
import chat.simplex.app.views.helpers.generalGetString
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
CIMsgError(ci, timedMessagesTTL, showMember) {
|
||||||
|
when (msgError) {
|
||||||
|
is MsgErrorType.MsgSkipped ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_skipped_messages),
|
||||||
|
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
||||||
|
)
|
||||||
|
is MsgErrorType.MsgBadHash ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_msg_bad_hash),
|
||||||
|
text = generalGetString(R.string.alert_text_msg_bad_hash) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_encryption_out_of_sync_old_database) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
|
)
|
||||||
|
is MsgErrorType.MsgBadId, is MsgErrorType.MsgDuplicate ->
|
||||||
|
AlertManager.shared.showAlertMsg(
|
||||||
|
title = generalGetString(R.string.alert_title_msg_bad_id),
|
||||||
|
text = generalGetString(R.string.alert_text_msg_bad_id) + "\n" +
|
||||||
|
generalGetString(R.string.alert_text_fragment_please_report_to_developers)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false, onClick: () -> Unit) {
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
Surface(
|
Surface(
|
||||||
Modifier.clickable(onClick = {
|
Modifier.clickable(onClick = onClick),
|
||||||
AlertManager.shared.showAlertMsg(
|
|
||||||
title = generalGetString(R.string.alert_title_skipped_messages),
|
|
||||||
text = generalGetString(R.string.alert_text_skipped_messages_it_can_happen_when)
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
color = ReceivedColorLight,
|
color = receivedColor,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
@@ -45,7 +70,7 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
|||||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
)
|
)
|
||||||
CIMetaView(ci)
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +84,9 @@ fun IntegrityErrorItemView(ci: ChatItem, showMember: Boolean = false) {
|
|||||||
fun IntegrityErrorItemViewPreview() {
|
fun IntegrityErrorItemViewPreview() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
IntegrityErrorItemView(
|
IntegrityErrorItemView(
|
||||||
ChatItem.getDeletedContentSampleData()
|
MsgErrorType.MsgBadHash(),
|
||||||
|
ChatItem.getDeletedContentSampleData(),
|
||||||
|
null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.model.CIDeleted
|
||||||
|
import chat.simplex.app.model.ChatItem
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.generalGetString
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showMember: Boolean = false) {
|
||||||
|
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
|
||||||
|
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = if (ci.chatDir.sent) sentColor else receivedColor,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f, false)) {
|
||||||
|
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
|
||||||
|
MarkedDeletedText(String.format(generalGetString(R.string.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
|
||||||
|
} else {
|
||||||
|
MarkedDeletedText(generalGetString(R.string.marked_deleted_description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CIMetaView(ci, timedMessagesTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MarkedDeletedText(text: String) {
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
// appendSender(this, if (showMember) ci.memberDisplayName else null, true) // TODO font size
|
||||||
|
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Preview(
|
||||||
|
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||||
|
name = "Dark Mode"
|
||||||
|
)
|
||||||
|
@Composable
|
||||||
|
fun PreviewMarkedDeletedItemView() {
|
||||||
|
SimpleXTheme {
|
||||||
|
DeletedItemView(
|
||||||
|
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted()),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
package chat.simplex.app.views.chat.item
|
package chat.simplex.app.views.chat.item
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.IntRange
|
||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.*
|
import androidx.compose.ui.platform.*
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.*
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.core.text.BidiFormatter
|
import androidx.core.text.BidiFormatter
|
||||||
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.CurrentColors
|
||||||
import chat.simplex.app.views.helpers.detectGesture
|
import chat.simplex.app.views.helpers.detectGesture
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
|
||||||
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
|
||||||
@@ -36,42 +46,101 @@ fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolea
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val noTyping: AnnotatedString = AnnotatedString(" ")
|
||||||
|
|
||||||
|
private val typingIndicators: List<AnnotatedString> = listOf(
|
||||||
|
typing(FontWeight.Black) + typing() + typing(),
|
||||||
|
typing(FontWeight.Bold) + typing(FontWeight.Black) + typing(),
|
||||||
|
typing() + typing(FontWeight.Bold) + typing(FontWeight.Black),
|
||||||
|
typing() + typing() + typing(FontWeight.Bold)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
private fun typingIndicator(recent: Boolean, @IntRange (from = 0, to = 4) typingIdx: Int): AnnotatedString = buildAnnotatedString {
|
||||||
|
pushStyle(SpanStyle(color = CurrentColors.value.colors.secondary, fontFamily = FontFamily.Monospace, letterSpacing = (-1).sp))
|
||||||
|
append(if (recent) typingIndicators[typingIdx] else noTyping)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString =
|
||||||
|
AnnotatedString(".", SpanStyle(fontWeight = w))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkdownText (
|
fun MarkdownText (
|
||||||
text: String,
|
text: CharSequence,
|
||||||
formattedText: List<FormattedText>? = null,
|
formattedText: List<FormattedText>? = null,
|
||||||
sender: String? = null,
|
sender: String? = null,
|
||||||
metaText: String? = null,
|
meta: CIMeta? = null,
|
||||||
edited: Boolean = false,
|
chatTTL: Int? = null,
|
||||||
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
overflow: TextOverflow = TextOverflow.Clip,
|
overflow: TextOverflow = TextOverflow.Clip,
|
||||||
uriHandler: UriHandler? = null,
|
uriHandler: UriHandler? = null,
|
||||||
senderBold: Boolean = false,
|
senderBold: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
linkMode: SimplexLinkMode,
|
||||||
|
inlineContent: Map<String, InlineTextContent>? = null,
|
||||||
onLinkLongClick: (link: String) -> Unit = {}
|
onLinkLongClick: (link: String) -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val textLayoutDirection = remember (text) {
|
val textLayoutDirection = remember (text) {
|
||||||
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
if (BidiFormatter.getInstance().isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||||
}
|
}
|
||||||
val reserve = when {
|
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||||
textLayoutDirection != LocalLayoutDirection.current && metaText != null -> "\n"
|
"\n"
|
||||||
edited -> " "
|
} else if (meta != null) {
|
||||||
else -> " "
|
reserveSpaceForMeta(meta, chatTTL)
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
}
|
}
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
|
LocalLayoutDirection provides if (textLayoutDirection != LocalLayoutDirection.current)
|
||||||
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
if (LocalLayoutDirection.current == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||||
else
|
else
|
||||||
LocalLayoutDirection.current
|
LocalLayoutDirection.current
|
||||||
) {
|
) {
|
||||||
|
var timer: Job? by remember { mutableStateOf(null) }
|
||||||
|
var typingIdx by rememberSaveable { mutableStateOf(0) }
|
||||||
|
fun stopTyping() {
|
||||||
|
timer?.cancel()
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
fun switchTyping() {
|
||||||
|
if (meta != null && meta.isLive && meta.recent) {
|
||||||
|
timer = timer ?: scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
typingIdx = (typingIdx + 1) % typingIndicators.size
|
||||||
|
delay(250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stopTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (meta?.isLive == true) {
|
||||||
|
val activity = LocalContext.current as Activity
|
||||||
|
LaunchedEffect(meta.recent, meta.isLive) {
|
||||||
|
switchTyping()
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val orientation = activity.resources.configuration.orientation
|
||||||
|
onDispose {
|
||||||
|
if (orientation == activity.resources.configuration.orientation) {
|
||||||
|
stopTyping()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (formattedText == null) {
|
if (formattedText == null) {
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
appendSender(this, sender, senderBold)
|
appendSender(this, sender, senderBold)
|
||||||
append(text)
|
if (text is String) append(text)
|
||||||
if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
else if (text is AnnotatedString) append(text)
|
||||||
|
if (meta?.isLive == true) {
|
||||||
|
append(typingIndicator(meta.recent, typingIdx))
|
||||||
|
}
|
||||||
|
if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||||
}
|
}
|
||||||
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
|
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf())
|
||||||
} else {
|
} else {
|
||||||
var hasLinks = false
|
var hasLinks = false
|
||||||
val annotatedText = buildAnnotatedString {
|
val annotatedText = buildAnnotatedString {
|
||||||
@@ -79,22 +148,29 @@ fun MarkdownText (
|
|||||||
for (ft in formattedText) {
|
for (ft in formattedText) {
|
||||||
if (ft.format == null) append(ft.text)
|
if (ft.format == null) append(ft.text)
|
||||||
else {
|
else {
|
||||||
val link = ft.link
|
val link = ft.link(linkMode)
|
||||||
if (link != null) {
|
if (link != null) {
|
||||||
hasLinks = true
|
hasLinks = true
|
||||||
val ftStyle = ft.format.style
|
val ftStyle = if (ft.format is Format.SimplexLink && !ft.format.trustedUri && linkMode == SimplexLinkMode.BROWSER) {
|
||||||
|
SpanStyle(color = Color.Red, textDecoration = TextDecoration.Underline)
|
||||||
|
} else {
|
||||||
|
ft.format.style
|
||||||
|
}
|
||||||
withAnnotation(tag = "URL", annotation = link) {
|
withAnnotation(tag = "URL", annotation = link) {
|
||||||
withStyle(ftStyle) { append(ft.text) }
|
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
withStyle(ft.format.style) { append(ft.text) }
|
withStyle(ft.format.style) { append(ft.text) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (meta?.isLive == true) {
|
||||||
|
append(typingIndicator(meta.recent, typingIdx))
|
||||||
|
}
|
||||||
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
|
// With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good
|
||||||
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
|
/*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl)
|
||||||
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
withStyle(reserveTimestampStyle) { append("\n" + metaText) }
|
||||||
else */if (metaText != null) withStyle(reserveTimestampStyle) { append(reserve + metaText) }
|
else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) }
|
||||||
}
|
}
|
||||||
if (hasLinks && uriHandler != null) {
|
if (hasLinks && uriHandler != null) {
|
||||||
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
|
||||||
@@ -104,7 +180,15 @@ fun MarkdownText (
|
|||||||
},
|
},
|
||||||
onClick = { offset ->
|
onClick = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||||
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
|
.firstOrNull()?.let { annotation ->
|
||||||
|
try {
|
||||||
|
uriHandler.openUri(annotation.item)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
|
||||||
|
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
|
||||||
|
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
shouldConsumeEvent = { offset ->
|
shouldConsumeEvent = { offset ->
|
||||||
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset).any()
|
||||||
@@ -144,7 +228,7 @@ fun ClickableText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, shouldConsumeEvent = { pos ->
|
}, shouldConsumeEvent = { pos ->
|
||||||
var consume = false
|
var consume = false
|
||||||
layoutResult.value?.let { layoutResult ->
|
layoutResult.value?.let { layoutResult ->
|
||||||
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.PersonAdd
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -19,6 +18,8 @@ import androidx.compose.ui.unit.sp
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
import chat.simplex.app.views.helpers.annotatedStringResource
|
import chat.simplex.app.views.helpers.annotatedStringResource
|
||||||
|
import chat.simplex.app.views.helpers.openUriCatching
|
||||||
|
import chat.simplex.app.views.usersettings.MarkdownHelpView
|
||||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||||
|
|
||||||
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
||||||
@@ -26,7 +27,6 @@ val bold = SpanStyle(fontWeight = FontWeight.Bold)
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
@@ -35,14 +35,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
Text(
|
Text(
|
||||||
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder),
|
||||||
modifier = Modifier.clickable(onClick = {
|
modifier = Modifier.clickable(onClick = {
|
||||||
uriHandler.openUri(simplexTeamUri)
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
}),
|
}),
|
||||||
lineHeight = 22.sp
|
lineHeight = 22.sp
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(top = 24.dp),
|
Modifier.padding(top = 24.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -56,7 +55,7 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.chat_help_tap_button))
|
Text(stringResource(R.string.chat_help_tap_button))
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.PersonAdd,
|
painterResource(R.drawable.ic_person_add),
|
||||||
stringResource(R.string.add_contact),
|
stringResource(R.string.add_contact),
|
||||||
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
|
||||||
)
|
)
|
||||||
@@ -68,7 +67,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(top = 24.dp),
|
Modifier.padding(top = 24.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
Text(stringResource(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2)
|
||||||
@@ -76,6 +74,14 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) {
|
|||||||
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp)
|
||||||
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.padding(vertical = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.markdown_in_messages), style = MaterialTheme.typography.h2)
|
||||||
|
MarkdownHelpView()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+87
-58
@@ -4,22 +4,25 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.chat.*
|
import chat.simplex.app.views.chat.*
|
||||||
import chat.simplex.app.views.chat.group.deleteGroupDialog
|
import chat.simplex.app.views.chat.group.deleteGroupDialog
|
||||||
import chat.simplex.app.views.chat.group.leaveGroupDialog
|
import chat.simplex.app.views.chat.group.leaveGroupDialog
|
||||||
|
import chat.simplex.app.views.chat.item.InvalidJSONView
|
||||||
import chat.simplex.app.views.chat.item.ItemAction
|
import chat.simplex.app.views.chat.item.ItemAction
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.newchat.ContactConnectionInfoView
|
import chat.simplex.app.views.newchat.ContactConnectionInfoView
|
||||||
@@ -33,22 +36,25 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
|
||||||
}
|
}
|
||||||
val stopped = chatModel.chatRunning.value == false
|
val stopped = chatModel.chatRunning.value == false
|
||||||
|
val linkMode by remember { chatModel.controller.appPrefs.simplexLinkMode.state }
|
||||||
LaunchedEffect(chat.id) {
|
LaunchedEffect(chat.id) {
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
delay(500L)
|
delay(500L)
|
||||||
}
|
}
|
||||||
when (chat.chatInfo) {
|
when (chat.chatInfo) {
|
||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct -> {
|
||||||
|
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
|
||||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||||
showMenu,
|
showMenu,
|
||||||
stopped
|
stopped
|
||||||
)
|
)
|
||||||
|
}
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
ChatListNavLinkLayout(
|
ChatListNavLinkLayout(
|
||||||
chatLinkPreview = { ChatPreviewView(chat, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, stopped) },
|
chatLinkPreview = { ChatPreviewView(chat, chatModel.draft.value, chatModel.draftChatId.value, chatModel.incognito.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
|
||||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||||
showMenu,
|
showMenu,
|
||||||
@@ -74,6 +80,18 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
showMenu,
|
showMenu,
|
||||||
stopped
|
stopped
|
||||||
)
|
)
|
||||||
|
is ChatInfo.InvalidJSON ->
|
||||||
|
ChatListNavLinkLayout(
|
||||||
|
chatLinkPreview = {
|
||||||
|
InvalidDataView()
|
||||||
|
},
|
||||||
|
click = {
|
||||||
|
ModalManager.shared.showModal(true) { InvalidJSONView(chat.chatInfo.json) }
|
||||||
|
},
|
||||||
|
dropdownMenuItems = null,
|
||||||
|
showMenu,
|
||||||
|
stopped
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +181,7 @@ fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showM
|
|||||||
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.mark_read),
|
stringResource(R.string.mark_read),
|
||||||
Icons.Outlined.Check,
|
painterResource(R.drawable.ic_check),
|
||||||
onClick = {
|
onClick = {
|
||||||
markChatRead(chat, chatModel)
|
markChatRead(chat, chatModel)
|
||||||
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
chatModel.controller.ntfManager.cancelNotificationsForChat(chat.id)
|
||||||
@@ -174,33 +192,21 @@ fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
DropdownMenuItem({
|
ItemAction(
|
||||||
markChatUnread(chat, chatModel)
|
stringResource(R.string.mark_unread),
|
||||||
showMenu.value = false
|
painterResource(R.drawable.ic_mark_chat_unread),
|
||||||
}) {
|
onClick = {
|
||||||
Row {
|
markChatUnread(chat, chatModel)
|
||||||
Text(
|
showMenu.value = false
|
||||||
stringResource(R.string.mark_unread),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1F)
|
|
||||||
.padding(end = 15.dp),
|
|
||||||
color = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Outlined.MarkChatUnread,
|
|
||||||
stringResource(R.string.mark_unread),
|
|
||||||
tint = MaterialTheme.colors.onBackground
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled: Boolean, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
if (ntfsEnabled) stringResource(R.string.mute_chat) else stringResource(R.string.unmute_chat),
|
||||||
if (ntfsEnabled) Icons.Outlined.NotificationsOff else Icons.Outlined.Notifications,
|
if (ntfsEnabled) painterResource(R.drawable.ic_notifications_off) else painterResource(R.drawable.ic_notifications),
|
||||||
onClick = {
|
onClick = {
|
||||||
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
changeNtfsStatePerChat(!ntfsEnabled, mutableStateOf(ntfsEnabled), chat, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -212,7 +218,7 @@ fun ToggleNotificationsChatAction(chat: Chat, chatModel: ChatModel, ntfsEnabled:
|
|||||||
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.clear_chat_menu_action),
|
stringResource(R.string.clear_chat_menu_action),
|
||||||
Icons.Outlined.Restore,
|
painterResource(R.drawable.ic_settings_backup_restore),
|
||||||
onClick = {
|
onClick = {
|
||||||
clearChatDialog(chat.chatInfo, chatModel)
|
clearChatDialog(chat.chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -225,7 +231,7 @@ fun ClearChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boo
|
|||||||
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_contact_menu_action),
|
stringResource(R.string.delete_contact_menu_action),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteContactDialog(chat.chatInfo, chatModel)
|
deleteContactDialog(chat.chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -238,7 +244,7 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
|
|||||||
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_group_menu_action),
|
stringResource(R.string.delete_group_menu_action),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
deleteGroupDialog(chat.chatInfo, groupInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -252,7 +258,7 @@ fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, show
|
|||||||
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
if (chat.chatInfo.incognito) stringResource(R.string.join_group_incognito_button) else stringResource(R.string.join_group_button),
|
||||||
if (chat.chatInfo.incognito) Icons.Filled.TheaterComedy else Icons.Outlined.Login,
|
if (chat.chatInfo.incognito) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_login),
|
||||||
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
color = if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.onBackground,
|
||||||
onClick = {
|
onClick = {
|
||||||
joinGroup()
|
joinGroup()
|
||||||
@@ -265,7 +271,7 @@ fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, show
|
|||||||
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.leave_group_button),
|
stringResource(R.string.leave_group_button),
|
||||||
Icons.Outlined.Logout,
|
painterResource(R.drawable.ic_logout),
|
||||||
onClick = {
|
onClick = {
|
||||||
leaveGroupDialog(groupInfo, chatModel)
|
leaveGroupDialog(groupInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -278,16 +284,16 @@ fun LeaveGroupAction(groupInfo: GroupInfo, chatModel: ChatModel, showMenu: Mutab
|
|||||||
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
if (chatModel.incognito.value) stringResource(R.string.accept_contact_incognito_button) else stringResource(R.string.accept_contact_button),
|
||||||
if (chatModel.incognito.value) Icons.Filled.TheaterComedy else Icons.Outlined.Check,
|
if (chatModel.incognito.value) painterResource(R.drawable.ic_theater_comedy_filled) else painterResource(R.drawable.ic_check),
|
||||||
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
color = if (chatModel.incognito.value) Indigo else MaterialTheme.colors.onBackground,
|
||||||
onClick = {
|
onClick = {
|
||||||
acceptContactRequest(chatInfo, chatModel)
|
acceptContactRequest(chatInfo.apiId, chatInfo, true, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.reject_contact_button),
|
stringResource(R.string.reject_contact_button),
|
||||||
Icons.Outlined.Close,
|
painterResource(R.drawable.ic_close),
|
||||||
onClick = {
|
onClick = {
|
||||||
rejectContactRequest(chatInfo, chatModel)
|
rejectContactRequest(chatInfo, chatModel)
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -300,7 +306,7 @@ fun ContactRequestMenuItems(chatInfo: ChatInfo.ContactRequest, chatModel: ChatMo
|
|||||||
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.set_contact_name),
|
stringResource(R.string.set_contact_name),
|
||||||
Icons.Outlined.Edit,
|
painterResource(R.drawable.ic_edit),
|
||||||
onClick = {
|
onClick = {
|
||||||
ModalManager.shared.showModalCloseable(true) { close ->
|
ModalManager.shared.showModalCloseable(true) { close ->
|
||||||
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
ContactConnectionInfoView(chatModel, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, true, close)
|
||||||
@@ -310,7 +316,7 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
|||||||
)
|
)
|
||||||
ItemAction(
|
ItemAction(
|
||||||
stringResource(R.string.delete_verb),
|
stringResource(R.string.delete_verb),
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
onClick = {
|
onClick = {
|
||||||
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
deleteContactConnectionAlert(chatInfo.contactConnection, chatModel) {}
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
@@ -319,6 +325,29 @@ fun ContactConnectionMenuItems(chatInfo: ChatInfo.ContactConnection, chatModel:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InvalidDataView() {
|
||||||
|
Row {
|
||||||
|
ProfileImage(72.dp, null, R.drawable.ic_account_circle_filled, MaterialTheme.colors.secondary)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.weight(1F)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.invalid_data),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.h3,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.Red
|
||||||
|
)
|
||||||
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
|
Spacer(Modifier.height(height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun markChatRead(c: Chat, chatModel: ChatModel) {
|
fun markChatRead(c: Chat, chatModel: ChatModel) {
|
||||||
var chat = c
|
var chat = c
|
||||||
withApi {
|
withApi {
|
||||||
@@ -366,16 +395,16 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel
|
|||||||
title = generalGetString(R.string.accept_connection_request__question),
|
title = generalGetString(R.string.accept_connection_request__question),
|
||||||
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified),
|
||||||
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
confirmText = if (chatModel.incognito.value) generalGetString(R.string.accept_contact_incognito_button) else generalGetString(R.string.accept_contact_button),
|
||||||
onConfirm = { acceptContactRequest(contactRequest, chatModel) },
|
onConfirm = { acceptContactRequest(contactRequest.apiId, contactRequest, true, chatModel) },
|
||||||
dismissText = generalGetString(R.string.reject_contact_button),
|
dismissText = generalGetString(R.string.reject_contact_button),
|
||||||
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
onDismiss = { rejectContactRequest(contactRequest, chatModel) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptContactRequest(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
|
fun acceptContactRequest(apiId: Long, contactRequest: ChatInfo.ContactRequest?, isCurrentUser: Boolean, chatModel: ChatModel) {
|
||||||
withApi {
|
withApi {
|
||||||
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
|
val contact = chatModel.controller.apiAcceptContactRequest(apiId)
|
||||||
if (contact != null) {
|
if (contact != null && isCurrentUser && contactRequest != null) {
|
||||||
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
val chat = Chat(ChatInfo.Direct(contact), listOf())
|
||||||
chatModel.replaceChat(contactRequest.id, chat)
|
chatModel.replaceChat(contactRequest.id, chat)
|
||||||
}
|
}
|
||||||
@@ -404,7 +433,7 @@ fun contactConnectionAlertDialog(connection: PendingContactConnection, chatModel
|
|||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
AlertManager.shared.hideAlert()
|
AlertManager.shared.hideAlert()
|
||||||
@@ -437,7 +466,8 @@ fun deleteContactConnectionAlert(connection: PendingContactConnection, chatModel
|
|||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,6 +485,7 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
destructive = true,
|
||||||
dismissText = generalGetString(R.string.cancel_verb),
|
dismissText = generalGetString(R.string.cancel_verb),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -536,27 +567,17 @@ fun ChatListNavLinkLayout(
|
|||||||
) {
|
) {
|
||||||
var modifier = Modifier.fillMaxWidth()
|
var modifier = Modifier.fillMaxWidth()
|
||||||
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
if (!stopped) modifier = modifier.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||||
Surface(modifier) {
|
Box(modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp)
|
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||||
.padding(start = 8.dp)
|
|
||||||
.padding(end = 12.dp),
|
|
||||||
verticalAlignment = Alignment.Top
|
verticalAlignment = Alignment.Top
|
||||||
) {
|
) {
|
||||||
chatLinkPreview()
|
chatLinkPreview()
|
||||||
}
|
}
|
||||||
if (dropdownMenuItems != null) {
|
if (dropdownMenuItems != null) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) {
|
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMenu.value,
|
|
||||||
onDismissRequest = { showMenu.value = false },
|
|
||||||
Modifier.width(220.dp)
|
|
||||||
) {
|
|
||||||
dropdownMenuItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Divider(Modifier.padding(horizontal = 8.dp))
|
Divider(Modifier.padding(horizontal = 8.dp))
|
||||||
@@ -586,9 +607,13 @@ fun PreviewChatListNavLinkDirect() {
|
|||||||
),
|
),
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
stopped = false
|
null,
|
||||||
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
click = {},
|
click = {},
|
||||||
@@ -623,9 +648,13 @@ fun PreviewChatListNavLinkGroup() {
|
|||||||
),
|
),
|
||||||
chatStats = Chat.ChatStats()
|
chatStats = Chat.ChatStats()
|
||||||
),
|
),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
stopped = false
|
null,
|
||||||
|
stopped = false,
|
||||||
|
linkMode = SimplexLinkMode.DESCRIPTION
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
click = {},
|
click = {},
|
||||||
|
|||||||
@@ -4,52 +4,71 @@ import androidx.activity.compose.BackHandler
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.*
|
import androidx.compose.foundation.lazy.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.intl.Locale
|
import androidx.compose.ui.text.intl.Locale
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
import chat.simplex.app.views.newchat.NewChatSheet
|
import chat.simplex.app.views.newchat.NewChatSheet
|
||||||
|
import chat.simplex.app.views.onboarding.WhatsNewView
|
||||||
|
import chat.simplex.app.views.onboarding.shouldShowWhatsNew
|
||||||
import chat.simplex.app.views.usersettings.SettingsView
|
import chat.simplex.app.views.usersettings.SettingsView
|
||||||
import chat.simplex.app.views.usersettings.simplexTeamUri
|
import chat.simplex.app.views.usersettings.simplexTeamUri
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
|
fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean, FragmentActivity) -> Unit, stopped: Boolean) {
|
||||||
val newChatSheetState by rememberSaveable(stateSaver = NewChatSheetState.saver()) { mutableStateOf(MutableStateFlow(NewChatSheetState.GONE)) }
|
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
val showNewChatSheet = {
|
val showNewChatSheet = {
|
||||||
newChatSheetState.value = NewChatSheetState.VISIBLE
|
newChatSheetState.value = AnimatedViewState.VISIBLE
|
||||||
}
|
}
|
||||||
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
|
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
|
||||||
if (animated) newChatSheetState.value = NewChatSheetState.HIDING
|
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
|
||||||
else newChatSheetState.value = NewChatSheetState.GONE
|
else newChatSheetState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (shouldShowWhatsNew(chatModel)) {
|
||||||
|
delay(1000L)
|
||||||
|
ModalManager.shared.showCustomModal { close -> WhatsNewView(close = close) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
LaunchedEffect(chatModel.clearOverlays.value) {
|
LaunchedEffect(chatModel.clearOverlays.value) {
|
||||||
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
|
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(chatModel.appOpenUrl.value) {
|
||||||
|
val url = chatModel.appOpenUrl.value
|
||||||
|
if (url != null) {
|
||||||
|
chatModel.appOpenUrl.value = null
|
||||||
|
connectIfOpenedViaUri(url, chatModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||||
val scaffoldState = rememberScaffoldState()
|
val scaffoldState = rememberScaffoldState()
|
||||||
Scaffold(
|
val scope = rememberCoroutineScope()
|
||||||
topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, stopped) { searchInList = it.trim() } },
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
|
Scaffold(topBar = { ChatListToolbar(chatModel, scaffoldState.drawerState, userPickerState, stopped) { searchInList = it.trim() } },
|
||||||
scaffoldState = scaffoldState,
|
scaffoldState = scaffoldState,
|
||||||
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
drawerContent = { SettingsView(chatModel, setPerformLA) },
|
||||||
|
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
if (searchInList.isEmpty()) {
|
if (searchInList.isEmpty()) {
|
||||||
FloatingActionButton(
|
FloatingActionButton(
|
||||||
@@ -58,16 +77,17 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
Modifier.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp),
|
||||||
elevation = FloatingActionButtonDefaults.elevation(
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
defaultElevation = 0.dp,
|
defaultElevation = 0.dp,
|
||||||
pressedElevation = 0.dp,
|
pressedElevation = 0.dp,
|
||||||
hoveredElevation = 0.dp,
|
hoveredElevation = 0.dp,
|
||||||
focusedElevation = 0.dp,
|
focusedElevation = 0.dp,
|
||||||
),
|
),
|
||||||
backgroundColor = if (!stopped) MaterialTheme.colors.primary else HighOrLowlight,
|
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||||
contentColor = Color.White
|
contentColor = Color.White
|
||||||
) {
|
) {
|
||||||
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) Icons.Default.Edit else Icons.Default.Close, stringResource(R.string.add_contact_or_create_group))
|
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(R.drawable.ic_edit_filled) else painterResource(R.drawable.ic_close), stringResource(R.string.add_contact_or_create_group))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,16 +96,15 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ChatList(chatModel, search = searchInList)
|
ChatList(chatModel, search = searchInList)
|
||||||
} else {
|
} else if (!switchingUsers.value) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
if (!stopped && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||||
OnboardingButtons(showNewChatSheet)
|
OnboardingButtons(showNewChatSheet)
|
||||||
}
|
}
|
||||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +113,17 @@ fun ChatListView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, stopped:
|
|||||||
if (searchInList.isEmpty()) {
|
if (searchInList.isEmpty()) {
|
||||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||||
}
|
}
|
||||||
|
UserPicker(chatModel, userPickerState, switchingUsers) {
|
||||||
|
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
|
||||||
|
}
|
||||||
|
if (switchingUsers.value) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().clickable(enabled = false, onClick = {}),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
ProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -101,11 +131,11 @@ private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
|
|||||||
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
ConnectButton(generalGetString(R.string.chat_with_developers)) {
|
||||||
uriHandler.openUri(simplexTeamUri)
|
uriHandler.openUriCatching(simplexTeamUri)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(DEFAULT_PADDING))
|
Spacer(Modifier.height(DEFAULT_PADDING))
|
||||||
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
ConnectButton(generalGetString(R.string.tap_to_start_new_chat), openNewChatSheet)
|
||||||
val color = MaterialTheme.colors.primary
|
val color = MaterialTheme.colors.primaryVariant
|
||||||
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
|
||||||
val trianglePath = Path().apply {
|
val trianglePath = Path().apply {
|
||||||
moveTo(0.dp.toPx(), 0f)
|
moveTo(0.dp.toPx(), 0f)
|
||||||
@@ -128,7 +158,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
|||||||
onClick,
|
onClick,
|
||||||
shape = RoundedCornerShape(21.dp),
|
shape = RoundedCornerShape(21.dp),
|
||||||
colors = ButtonDefaults.textButtonColors(
|
colors = ButtonDefaults.textButtonColors(
|
||||||
backgroundColor = MaterialTheme.colors.primary
|
backgroundColor = MaterialTheme.colors.primaryVariant
|
||||||
),
|
),
|
||||||
elevation = null,
|
elevation = null,
|
||||||
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
|
||||||
@@ -139,7 +169,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||||
if (showSearch) {
|
if (showSearch) {
|
||||||
@@ -149,7 +179,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
|||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +192,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
|||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Report,
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(R.string.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
@@ -172,10 +202,23 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
navigationButton = {
|
navigationButton = {
|
||||||
if (showSearch)
|
if (showSearch) {
|
||||||
NavigationButtonBack(hideSearchOnBack)
|
NavigationButtonBack(hideSearchOnBack)
|
||||||
else
|
} else if (chatModel.users.isEmpty()) {
|
||||||
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
|
||||||
|
} else {
|
||||||
|
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||||
|
val allRead = users
|
||||||
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
|
.all { u -> u.unreadCount == 0 }
|
||||||
|
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||||
|
if (users.size == 1) {
|
||||||
|
scope.launch { drawerState.open() }
|
||||||
|
} else {
|
||||||
|
userPickerState.value = AnimatedViewState.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -186,7 +229,7 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
|||||||
)
|
)
|
||||||
if (chatModel.incognito.value) {
|
if (chatModel.incognito.value) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy,
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
stringResource(R.string.incognito),
|
stringResource(R.string.incognito),
|
||||||
tint = Indigo,
|
tint = Indigo,
|
||||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
@@ -202,6 +245,47 @@ private fun ChatListToolbar(chatModel: ChatModel, drawerState: DrawerState, stop
|
|||||||
Divider(Modifier.padding(top = AppBarHeight))
|
Divider(Modifier.padding(top = AppBarHeight))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
|
||||||
|
IconButton(onClick = onButtonClicked) {
|
||||||
|
Box {
|
||||||
|
ProfileImage(
|
||||||
|
image = image,
|
||||||
|
size = 37.dp
|
||||||
|
)
|
||||||
|
if (!allRead) {
|
||||||
|
unreadBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.unreadBadge(text: String? = "") {
|
||||||
|
Text(
|
||||||
|
text ?: "",
|
||||||
|
color = MaterialTheme.colors.onPrimary,
|
||||||
|
fontSize = 6.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colors.primary, shape = CircleShape)
|
||||||
|
.badgeLayout()
|
||||||
|
.padding(horizontal = 3.dp)
|
||||||
|
.padding(vertical = 1.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProgressIndicator() {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
Modifier
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.size(30.dp),
|
||||||
|
color = MaterialTheme.colors.secondary,
|
||||||
|
strokeWidth = 2.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var lazyListState = 0 to 0
|
private var lazyListState = 0 to 0
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+103
-31
@@ -4,17 +4,17 @@ import android.content.res.Configuration
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.material.icons.filled.Cancel
|
|
||||||
import androidx.compose.material.icons.filled.NotificationsOff
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -22,20 +22,31 @@ import androidx.compose.ui.unit.*
|
|||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.chat.ComposePreview
|
||||||
|
import chat.simplex.app.views.chat.ComposeState
|
||||||
import chat.simplex.app.views.chat.item.MarkdownText
|
import chat.simplex.app.views.chat.item.MarkdownText
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileDisplayName: String?, stopped: Boolean) {
|
fun ChatPreviewView(
|
||||||
|
chat: Chat,
|
||||||
|
chatModelDraft: ComposeState?,
|
||||||
|
chatModelDraftChatId: ChatId?,
|
||||||
|
chatModelIncognito: Boolean,
|
||||||
|
currentUserProfileDisplayName: String?,
|
||||||
|
contactNetworkStatus: NetworkStatus?,
|
||||||
|
stopped: Boolean,
|
||||||
|
linkMode: SimplexLinkMode
|
||||||
|
) {
|
||||||
val cInfo = chat.chatInfo
|
val cInfo = chat.chatInfo
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun groupInactiveIcon() {
|
fun groupInactiveIcon() {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Cancel,
|
painterResource(R.drawable.ic_cancel_filled),
|
||||||
stringResource(R.string.icon_descr_group_inactive),
|
stringResource(R.string.icon_descr_group_inactive),
|
||||||
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
|
||||||
tint = HighOrLowlight
|
tint = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,15 +74,62 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VerifiedIcon() {
|
||||||
|
Icon(painterResource(R.drawable.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun messageDraft(draft: ComposeState): Pair<AnnotatedString, Map<String, InlineTextContent>> {
|
||||||
|
fun attachment(): Pair<Int, String?>? =
|
||||||
|
when (draft.preview) {
|
||||||
|
is ComposePreview.FilePreview -> R.drawable.ic_draft_filled to draft.preview.fileName
|
||||||
|
is ComposePreview.MediaPreview -> R.drawable.ic_image to null
|
||||||
|
is ComposePreview.VoicePreview -> R.drawable.ic_play_arrow_filled to durationText(draft.preview.durationMs / 1000)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
val attachment = attachment()
|
||||||
|
val text = buildAnnotatedString {
|
||||||
|
appendInlineContent(id = "editIcon")
|
||||||
|
append(" ")
|
||||||
|
if (attachment != null) {
|
||||||
|
appendInlineContent(id = "attachmentIcon")
|
||||||
|
if (attachment.second != null) {
|
||||||
|
append(attachment.second as String)
|
||||||
|
}
|
||||||
|
append(" ")
|
||||||
|
}
|
||||||
|
append(draft.message)
|
||||||
|
}
|
||||||
|
val inlineContent: Map<String, InlineTextContent> = mapOf(
|
||||||
|
"editIcon" to InlineTextContent(
|
||||||
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
) {
|
||||||
|
Icon(painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.primary)
|
||||||
|
},
|
||||||
|
"attachmentIcon" to InlineTextContent(
|
||||||
|
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
) {
|
||||||
|
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(R.drawable.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return text to inlineContent
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun chatPreviewTitle() {
|
fun chatPreviewTitle() {
|
||||||
when (cInfo) {
|
when (cInfo) {
|
||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct ->
|
||||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else HighOrLowlight)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (cInfo.contact.verified) {
|
||||||
|
VerifiedIcon()
|
||||||
|
}
|
||||||
|
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
when (cInfo.groupInfo.membership.memberStatus) {
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
|
||||||
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(HighOrLowlight)
|
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary)
|
||||||
else -> chatPreviewTitleText()
|
else -> chatPreviewTitleText()
|
||||||
}
|
}
|
||||||
else -> chatPreviewTitleText()
|
else -> chatPreviewTitleText()
|
||||||
@@ -82,27 +140,42 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
fun chatPreviewText(chatModelIncognito: Boolean) {
|
fun chatPreviewText(chatModelIncognito: Boolean) {
|
||||||
val ci = chat.chatItems.lastOrNull()
|
val ci = chat.chatItems.lastOrNull()
|
||||||
if (ci != null) {
|
if (ci != null) {
|
||||||
|
val (text: CharSequence, inlineTextContent) = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) }
|
||||||
|
ci.meta.itemDeleted == null -> ci.text to null
|
||||||
|
else -> generalGetString(R.string.marked_deleted_description) to null
|
||||||
|
}
|
||||||
|
val formattedText = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||||
|
ci.meta.itemDeleted == null -> ci.formattedText
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
MarkdownText(
|
MarkdownText(
|
||||||
ci.text,
|
text,
|
||||||
ci.formattedText,
|
formattedText,
|
||||||
sender = if (cInfo is ChatInfo.Group && !ci.chatDir.sent) ci.memberDisplayName else null,
|
sender = when {
|
||||||
|
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||||
|
cInfo is ChatInfo.Group && !ci.chatDir.sent -> ci.memberDisplayName
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
linkMode = linkMode,
|
||||||
senderBold = true,
|
senderBold = true,
|
||||||
metaText = null,
|
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
style = MaterialTheme.typography.body1.copy(color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight, lineHeight = 22.sp),
|
||||||
|
inlineContent = inlineTextContent,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
when (cInfo) {
|
when (cInfo) {
|
||||||
is ChatInfo.Direct ->
|
is ChatInfo.Direct ->
|
||||||
if (!cInfo.ready) {
|
if (!cInfo.ready) {
|
||||||
Text(stringResource(R.string.contact_connection_pending), color = HighOrLowlight)
|
Text(stringResource(R.string.contact_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
is ChatInfo.Group ->
|
is ChatInfo.Group ->
|
||||||
when (cInfo.groupInfo.membership.memberStatus) {
|
when (cInfo.groupInfo.membership.memberStatus) {
|
||||||
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
GroupMemberStatus.MemInvited -> Text(groupInvitationPreviewText(chatModelIncognito, currentUserProfileDisplayName, cInfo.groupInfo))
|
||||||
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = HighOrLowlight)
|
GroupMemberStatus.MemAccepted -> Text(stringResource(R.string.group_connection_pending), color = MaterialTheme.colors.secondary)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
@@ -135,7 +208,7 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
@@ -148,10 +221,10 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (n > 0) unreadCountStr(n) else "",
|
if (n > 0) unreadCountStr(n) else "",
|
||||||
color = MaterialTheme.colors.onPrimary,
|
color = Color.White,
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (stopped || showNtfsIcon) HighOrLowlight else MaterialTheme.colors.primary, shape = CircleShape)
|
.background(if (stopped || showNtfsIcon) MaterialTheme.colors.secondary else MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
.badgeLayout()
|
.badgeLayout()
|
||||||
.padding(horizontal = 3.dp)
|
.padding(horizontal = 3.dp)
|
||||||
.padding(vertical = 1.dp)
|
.padding(vertical = 1.dp)
|
||||||
@@ -163,9 +236,9 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.NotificationsOff,
|
painterResource(R.drawable.ic_notifications_off_filled),
|
||||||
contentDescription = generalGetString(R.string.notifications),
|
contentDescription = generalGetString(R.string.notifications),
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 3.dp)
|
.padding(horizontal = 3.dp)
|
||||||
.padding(vertical = 1.dp)
|
.padding(vertical = 1.dp)
|
||||||
@@ -178,7 +251,7 @@ fun ChatPreviewView(chat: Chat, chatModelIncognito: Boolean, currentUserProfileD
|
|||||||
Modifier.padding(top = 52.dp),
|
Modifier.padding(top = 52.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
ChatStatusImage(chat)
|
ChatStatusImage(contactNetworkStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,23 +274,22 @@ fun unreadCountStr(n: Int): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatStatusImage(chat: Chat) {
|
fun ChatStatusImage(s: NetworkStatus?) {
|
||||||
val s = chat.serverInfo.networkStatus
|
val descr = s?.statusString
|
||||||
val descr = s.statusString
|
if (s is NetworkStatus.Error) {
|
||||||
if (s is Chat.NetworkStatus.Error) {
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.ErrorOutline,
|
painterResource(R.drawable.ic_error),
|
||||||
contentDescription = descr,
|
contentDescription = descr,
|
||||||
tint = HighOrLowlight,
|
tint = MaterialTheme.colors.secondary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(19.dp)
|
.size(19.dp)
|
||||||
)
|
)
|
||||||
} else if (s !is Chat.NetworkStatus.Connected) {
|
} else if (s !is NetworkStatus.Connected) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(15.dp),
|
.size(15.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 1.5.dp
|
strokeWidth = 1.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -232,6 +304,6 @@ fun ChatStatusImage(chat: Chat) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun PreviewChatPreviewView() {
|
fun PreviewChatPreviewView() {
|
||||||
SimpleXTheme {
|
SimpleXTheme {
|
||||||
ChatPreviewView(Chat.sampleData, false, "", stopped = false)
|
ChatPreviewView(Chat.sampleData, null, null, false, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-6
@@ -3,16 +3,16 @@ package chat.simplex.app.views.chatlist
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.ProfileImage
|
import chat.simplex.app.views.helpers.ProfileImage
|
||||||
@@ -21,7 +21,7 @@ import chat.simplex.app.views.helpers.ProfileImage
|
|||||||
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
||||||
Row {
|
Row {
|
||||||
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.size(72.dp), contentAlignment = Alignment.Center) {
|
||||||
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) Icons.Outlined.AddLink else Icons.Outlined.Link)
|
ProfileImage(size = 54.dp, null, if (contactConnection.initiated) R.drawable.ic_add_link else R.drawable.ic_link)
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -34,7 +34,7 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.h3,
|
style = MaterialTheme.typography.h3,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
val height = with(LocalDensity.current) { 46.sp.toDp() }
|
||||||
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
Text(contactConnection.description, Modifier.heightIn(min = height), maxLines = 2, color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight)
|
||||||
@@ -42,11 +42,10 @@ fun ContactConnectionView(contactConnection: PendingContactConnection) {
|
|||||||
val ts = getTimestampText(contactConnection.updatedAt)
|
val ts = getTimestampText(contactConnection.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxHeight(),
|
Modifier.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-2
@@ -39,11 +39,10 @@ fun ContactRequestView(chatModelIncognito: Boolean, contactRequest: ChatInfo.Con
|
|||||||
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
val ts = getTimestampText(contactRequest.contactRequest.updatedAt)
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxHeight(),
|
Modifier.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Top
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
ts,
|
ts,
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
style = MaterialTheme.typography.body2,
|
style = MaterialTheme.typography.body2,
|
||||||
modifier = Modifier.padding(bottom = 5.dp)
|
modifier = Modifier.padding(bottom = 5.dp)
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ fun ShareListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
|||||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||||
stopped
|
stopped
|
||||||
)
|
)
|
||||||
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection -> {}
|
is ChatInfo.ContactRequest, is ChatInfo.ContactConnection, is ChatInfo.InvalidJSON -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.capitalize
|
import androidx.compose.ui.text.capitalize
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -21,21 +19,23 @@ import androidx.compose.ui.text.intl.Locale
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.ui.theme.Indigo
|
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
||||||
var searchInList by rememberSaveable { mutableStateOf("") }
|
var searchInList by rememberSaveable { mutableStateOf("") }
|
||||||
|
val userPickerState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
|
||||||
|
val switchingUsers = rememberSaveable { mutableStateOf(false) }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = { Column { ShareListToolbar(chatModel, stopped) { searchInList = it.trim() } } },
|
topBar = { Column { ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() } } },
|
||||||
) {
|
) {
|
||||||
Box(Modifier.padding(it)) {
|
Box(Modifier.padding(it)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colors.background)
|
.themedBackground()
|
||||||
) {
|
) {
|
||||||
if (chatModel.chats.isNotEmpty()) {
|
if (chatModel.chats.isNotEmpty()) {
|
||||||
ShareList(chatModel, search = searchInList)
|
ShareList(chatModel, search = searchInList)
|
||||||
@@ -45,27 +45,45 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
UserPicker(chatModel, userPickerState, switchingUsers, showSettings = false, showCancel = true, cancelClicked = {
|
||||||
|
chatModel.sharedContent.value = null
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyList() {
|
private fun EmptyList() {
|
||||||
Box {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text(stringResource(R.string.you_have_no_chats), Modifier.align(Alignment.Center), color = HighOrLowlight)
|
Text(stringResource(R.string.you_have_no_chats), color = MaterialTheme.colors.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
|
||||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||||
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
|
||||||
if (showSearch) {
|
if (showSearch) {
|
||||||
BackHandler(onBack = hideSearchOnBack)
|
BackHandler(onBack = hideSearchOnBack)
|
||||||
}
|
}
|
||||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||||
|
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
|
||||||
|
val navButton: @Composable RowScope.() -> Unit = {
|
||||||
|
when {
|
||||||
|
showSearch -> NavigationButtonBack(hideSearchOnBack)
|
||||||
|
users.size > 1 -> {
|
||||||
|
val allRead = users
|
||||||
|
.filter { u -> !u.user.activeUser && !u.user.hidden }
|
||||||
|
.all { u -> u.unreadCount == 0 }
|
||||||
|
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
|
||||||
|
userPickerState.value = AnimatedViewState.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> NavigationButtonBack { chatModel.sharedContent.value = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (chatModel.chats.size >= 8) {
|
if (chatModel.chats.size >= 8) {
|
||||||
barButtons.add {
|
barButtons.add {
|
||||||
IconButton({ showSearch = true }) {
|
IconButton({ showSearch = true }) {
|
||||||
Icon(Icons.Outlined.Search, stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
Icon(painterResource(R.drawable.ic_search_500), stringResource(android.R.string.search_go).capitalize(Locale.current), tint = MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +96,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.Report,
|
painterResource(R.drawable.ic_report_filled),
|
||||||
generalGetString(R.string.chat_is_stopped_indication),
|
generalGetString(R.string.chat_is_stopped_indication),
|
||||||
tint = Color.Red,
|
tint = Color.Red,
|
||||||
)
|
)
|
||||||
@@ -87,13 +105,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
}
|
}
|
||||||
|
|
||||||
DefaultTopAppBar(
|
DefaultTopAppBar(
|
||||||
navigationButton = { if (showSearch) NavigationButtonBack(hideSearchOnBack) else NavigationButtonBack { chatModel.sharedContent.value = null } },
|
navigationButton = navButton,
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
when (chatModel.sharedContent.value) {
|
when (chatModel.sharedContent.value) {
|
||||||
is SharedContent.Text -> stringResource(R.string.share_message)
|
is SharedContent.Text -> stringResource(R.string.share_message)
|
||||||
is SharedContent.Images -> stringResource(R.string.share_image)
|
is SharedContent.Media -> stringResource(R.string.share_image)
|
||||||
is SharedContent.File -> stringResource(R.string.share_file)
|
is SharedContent.File -> stringResource(R.string.share_file)
|
||||||
else -> stringResource(R.string.share_message)
|
else -> stringResource(R.string.share_message)
|
||||||
},
|
},
|
||||||
@@ -102,7 +120,7 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
|
|||||||
)
|
)
|
||||||
if (chatModel.incognito.value) {
|
if (chatModel.incognito.value) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy,
|
painterResource(R.drawable.ic_theater_comedy_filled),
|
||||||
stringResource(R.string.incognito),
|
stringResource(R.string.incognito),
|
||||||
tint = Indigo,
|
tint = Indigo,
|
||||||
modifier = Modifier.padding(10.dp).size(26.dp)
|
modifier = Modifier.padding(10.dp).size(26.dp)
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package chat.simplex.app.views.chatlist
|
||||||
|
|
||||||
|
import SectionItemView
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.*
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.*
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.*
|
||||||
|
import androidx.compose.ui.draw.*
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.capitalize
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.model.*
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserPicker(
|
||||||
|
chatModel: ChatModel,
|
||||||
|
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||||
|
switchingUsers: MutableState<Boolean>,
|
||||||
|
showSettings: Boolean = true,
|
||||||
|
showCancel: Boolean = false,
|
||||||
|
cancelClicked: () -> Unit = {},
|
||||||
|
settingsClicked: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var newChat by remember { mutableStateOf(userPickerState.value) }
|
||||||
|
val users by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
chatModel.users
|
||||||
|
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||||
|
.sortedByDescending { it.user.activeUser }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
userPickerState.collect {
|
||||||
|
newChat = it
|
||||||
|
launch {
|
||||||
|
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
|
||||||
|
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { newChat.isVisible() }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.filter { it }
|
||||||
|
.collect {
|
||||||
|
try {
|
||||||
|
val updatedUsers = chatModel.controller.listUsers().sortedByDescending { it.user.activeUser }
|
||||||
|
var same = users.size == updatedUsers.size
|
||||||
|
if (same) {
|
||||||
|
for (i in 0 until minOf(users.size, updatedUsers.size)) {
|
||||||
|
val prev = updatedUsers[i].user
|
||||||
|
val next = users[i].user
|
||||||
|
if (prev.userId != next.userId || prev.activeUser != next.activeUser || prev.chatViewName != next.chatViewName || prev.image != next.image) {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!same) {
|
||||||
|
chatModel.users.clear()
|
||||||
|
chatModel.users.addAll(updatedUsers)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error updating users ${e.stackTraceToString()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
|
||||||
|
val maxWidth = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp * density }
|
||||||
|
Box(Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.offset { IntOffset(if (newChat.isGone()) -maxWidth.roundToInt() else xOffset, 0) }
|
||||||
|
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
|
||||||
|
.padding(bottom = 10.dp, top = 10.dp)
|
||||||
|
.graphicsLayer {
|
||||||
|
alpha = animatedFloat.value
|
||||||
|
translationY = (animatedFloat.value - 1) * xOffset
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.widthIn(min = 260.dp)
|
||||||
|
.width(IntrinsicSize.Min)
|
||||||
|
.height(IntrinsicSize.Min)
|
||||||
|
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
|
||||||
|
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
|
||||||
|
users.forEach { u ->
|
||||||
|
UserProfilePickerItem(u.user, u.unreadCount, PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), openSettings = {
|
||||||
|
settingsClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}) {
|
||||||
|
userPickerState.value = AnimatedViewState.HIDING
|
||||||
|
if (!u.user.activeUser) {
|
||||||
|
scope.launch {
|
||||||
|
val job = launch {
|
||||||
|
delay(500)
|
||||||
|
switchingUsers.value = true
|
||||||
|
}
|
||||||
|
chatModel.controller.changeActiveUser(u.user.userId, null)
|
||||||
|
job.cancel()
|
||||||
|
switchingUsers.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider(Modifier.requiredHeight(1.dp))
|
||||||
|
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showSettings) {
|
||||||
|
SettingsPickerItem {
|
||||||
|
settingsClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showCancel) {
|
||||||
|
CancelPickerItem {
|
||||||
|
cancelClicked()
|
||||||
|
userPickerState.value = AnimatedViewState.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfilePickerItem(u: User, unreadCount: Int = 0, padding: PaddingValues = PaddingValues(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING), onLongClick: () -> Unit = {}, openSettings: () -> Unit = {}, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.sizeIn(minHeight = 46.dp)
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = if (u.activeUser) openSettings else onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = if (!u.activeUser) LocalIndication.current else null
|
||||||
|
)
|
||||||
|
.padding(padding),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
UserProfileRow(u)
|
||||||
|
if (u.activeUser) {
|
||||||
|
Icon(painterResource(R.drawable.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
} else if (u.hidden) {
|
||||||
|
Icon(painterResource(R.drawable.ic_lock), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
} else if (unreadCount > 0) {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
unreadCountStr(unreadCount),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colors.primaryVariant, shape = CircleShape)
|
||||||
|
.padding(2.dp)
|
||||||
|
.badgeLayout()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (!u.showNtfs) {
|
||||||
|
Icon(painterResource(R.drawable.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||||
|
} else {
|
||||||
|
Box(Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfileRow(u: User) {
|
||||||
|
Row(
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.7f)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
ProfileImage(
|
||||||
|
image = u.image,
|
||||||
|
size = 54.dp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
u.displayName,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 10.dp, end = 8.dp),
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||||
|
Icon(painterResource(R.drawable.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CancelPickerItem(onClick: () -> Unit) {
|
||||||
|
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
|
||||||
|
val text = generalGetString(R.string.cancel_verb)
|
||||||
|
Icon(painterResource(R.drawable.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||||
|
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,16 +13,14 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
import chat.simplex.app.model.ChatModel
|
import chat.simplex.app.model.ChatModel
|
||||||
@@ -57,20 +55,18 @@ fun ChatArchiveLayout(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(title)
|
AppBarTitle(title)
|
||||||
SectionView(stringResource(R.string.chat_archive_section)) {
|
SectionView(stringResource(R.string.chat_archive_section)) {
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.IosShare,
|
painterResource(R.drawable.ic_ios_share),
|
||||||
stringResource(R.string.save_archive),
|
stringResource(R.string.save_archive),
|
||||||
saveArchive,
|
saveArchive,
|
||||||
textColor = MaterialTheme.colors.primary,
|
textColor = MaterialTheme.colors.primary,
|
||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Delete,
|
painterResource(R.drawable.ic_delete),
|
||||||
stringResource(R.string.delete_archive),
|
stringResource(R.string.delete_archive),
|
||||||
deleteArchiveAlert,
|
deleteArchiveAlert,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -81,6 +77,7 @@ fun ChatArchiveLayout(
|
|||||||
SectionTextFooter(
|
SectionTextFooter(
|
||||||
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
String.format(generalGetString(R.string.archive_created_on_ts), archiveTs)
|
||||||
)
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +116,8 @@ private fun deleteArchiveAlert(m: ChatModel, archivePath: String) {
|
|||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "deleteArchiveAlert delete() error")
|
Log.e(TAG, "deleteArchiveAlert delete() error")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-32
@@ -1,5 +1,6 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionItemViewSpaceBetween
|
import SectionItemViewSpaceBetween
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
@@ -11,27 +12,25 @@ import androidx.compose.foundation.shape.ZeroCornerSize
|
|||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.*
|
import androidx.compose.ui.*
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.*
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.*
|
import androidx.compose.ui.text.input.*
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.*
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.SimplexApp
|
import chat.simplex.app.SimplexApp
|
||||||
import chat.simplex.app.model.*
|
import chat.simplex.app.model.*
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlin.math.log2
|
import kotlin.math.log2
|
||||||
|
|
||||||
@@ -41,9 +40,9 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
val prefs = m.controller.appPrefs
|
val prefs = m.controller.appPrefs
|
||||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||||
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
val initialRandomDBPassphrase = remember { mutableStateOf(prefs.initialRandomDBPassphrase.get()) }
|
||||||
val storedKey = remember { val key = DatabaseUtils.getDatabaseKey(); mutableStateOf(key != null && key != "") }
|
val storedKey = remember { val key = DatabaseUtils.ksDatabasePassword.get(); mutableStateOf(key != null && key != "") }
|
||||||
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
// Do not do rememberSaveable on current key to prevent saving it on disk in clear text
|
||||||
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.getDatabaseKey() ?: "" else "") }
|
val currentKey = remember { mutableStateOf(if (initialRandomDBPassphrase.value) DatabaseUtils.ksDatabasePassword.get() ?: "" else "") }
|
||||||
val newKey = rememberSaveable { mutableStateOf("") }
|
val newKey = rememberSaveable { mutableStateOf("") }
|
||||||
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
val confirmNewKey = rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
@@ -88,7 +87,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
prefs.initialRandomDBPassphrase.set(false)
|
prefs.initialRandomDBPassphrase.set(false)
|
||||||
initialRandomDBPassphrase.value = false
|
initialRandomDBPassphrase.value = false
|
||||||
if (useKeychain.value) {
|
if (useKeychain.value) {
|
||||||
DatabaseUtils.setDatabaseKey(newKey.value)
|
DatabaseUtils.ksDatabasePassword.set(newKey.value)
|
||||||
}
|
}
|
||||||
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
resetFormAfterEncryption(m, initialRandomDBPassphrase, currentKey, newKey, confirmNewKey, storedKey, useKeychain.value)
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
@@ -113,7 +112,7 @@ fun DatabaseEncryptionView(m: ChatModel) {
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,7 +135,6 @@ fun DatabaseEncryptionLayout(
|
|||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.database_passphrase))
|
AppBarTitle(stringResource(R.string.database_passphrase))
|
||||||
SectionView(null) {
|
SectionView(null) {
|
||||||
@@ -149,7 +147,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
text = generalGetString(R.string.notifications_will_be_hidden) + "\n" + storeSecurelyDanger(),
|
||||||
confirmText = generalGetString(R.string.remove_passphrase),
|
confirmText = generalGetString(R.string.remove_passphrase),
|
||||||
onConfirm = {
|
onConfirm = {
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
setUseKeychain(false, useKeychain, prefs)
|
setUseKeychain(false, useKeychain, prefs)
|
||||||
storedKey.value = false
|
storedKey.value = false
|
||||||
},
|
},
|
||||||
@@ -161,7 +159,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
currentKey,
|
currentKey,
|
||||||
generalGetString(R.string.current_passphrase),
|
generalGetString(R.string.current_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -170,7 +168,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
newKey,
|
newKey,
|
||||||
generalGetString(R.string.new_passphrase),
|
generalGetString(R.string.new_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -201,7 +199,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
!validKey(newKey.value) ||
|
!validKey(newKey.value) ||
|
||||||
progressIndicator.value
|
progressIndicator.value
|
||||||
|
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
confirmNewKey,
|
confirmNewKey,
|
||||||
generalGetString(R.string.confirm_new_passphrase),
|
generalGetString(R.string.confirm_new_passphrase),
|
||||||
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
|
||||||
@@ -212,8 +210,8 @@ fun DatabaseEncryptionLayout(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled) {
|
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
|
||||||
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) HighOrLowlight else MaterialTheme.colors.primary)
|
Text(generalGetString(R.string.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +234,7 @@ fun DatabaseEncryptionLayout(
|
|||||||
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
SectionTextFooter(generalGetString(R.string.impossible_to_recover_passphrase))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +244,7 @@ fun encryptDatabaseSavedAlert(onConfirm: () -> Unit) {
|
|||||||
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
text = generalGetString(R.string.database_will_be_encrypted_and_passphrase_stored) + "\n" + storeSecurelySaved(),
|
||||||
confirmText = generalGetString(R.string.encrypt_database),
|
confirmText = generalGetString(R.string.encrypt_database),
|
||||||
onConfirm = onConfirm,
|
onConfirm = onConfirm,
|
||||||
destructive = false,
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,14 +284,15 @@ fun SavePassphraseSetting(
|
|||||||
initialRandomDBPassphrase: Boolean,
|
initialRandomDBPassphrase: Boolean,
|
||||||
storedKey: Boolean,
|
storedKey: Boolean,
|
||||||
progressIndicator: Boolean,
|
progressIndicator: Boolean,
|
||||||
|
minHeight: Dp = TextFieldDefaults.MinHeight,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
SectionItemView {
|
SectionItemView(minHeight = minHeight) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
Icon(
|
||||||
if (storedKey) Icons.Filled.VpnKey else Icons.Filled.VpnKeyOff,
|
if (storedKey) painterResource(R.drawable.ic_vpn_key_filled) else painterResource(R.drawable.ic_vpn_key_off_filled),
|
||||||
stringResource(R.string.save_passphrase_in_keychain),
|
stringResource(R.string.save_passphrase_in_keychain),
|
||||||
tint = if (storedKey) SimplexGreen else HighOrLowlight
|
tint = if (storedKey) SimplexGreen else MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -301,13 +301,9 @@ fun SavePassphraseSetting(
|
|||||||
color = Color.Unspecified
|
color = Color.Unspecified
|
||||||
)
|
)
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
Spacer(Modifier.fillMaxWidth().weight(1f))
|
||||||
Switch(
|
DefaultSwitch(
|
||||||
checked = useKeychain,
|
checked = useKeychain,
|
||||||
onCheckedChange = onCheckedChange,
|
onCheckedChange = onCheckedChange,
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
enabled = !initialRandomDBPassphrase && !progressIndicator
|
enabled = !initialRandomDBPassphrase && !progressIndicator
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -349,21 +345,22 @@ private fun operationEnded(m: ChatModel, progressIndicator: MutableState<Boolean
|
|||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun DatabaseKeyField(
|
fun PassphraseField(
|
||||||
key: MutableState<String>,
|
key: MutableState<String>,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showStrength: Boolean = false,
|
showStrength: Boolean = false,
|
||||||
isValid: (String) -> Boolean,
|
isValid: (String) -> Boolean,
|
||||||
keyboardActions: KeyboardActions = KeyboardActions(),
|
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||||
|
dependsOn: State<Any?>? = null,
|
||||||
) {
|
) {
|
||||||
var valid by remember { mutableStateOf(validKey(key.value)) }
|
var valid by remember { mutableStateOf(validKey(key.value)) }
|
||||||
var showKey by remember { mutableStateOf(false) }
|
var showKey by remember { mutableStateOf(false) }
|
||||||
val icon = if (valid) {
|
val icon = if (valid) {
|
||||||
if (showKey) Icons.Filled.VisibilityOff else Icons.Filled.Visibility
|
if (showKey) painterResource(R.drawable.ic_visibility_off_filled) else painterResource(R.drawable.ic_visibility_filled)
|
||||||
} else Icons.Outlined.Error
|
} else painterResource(R.drawable.ic_error)
|
||||||
val iconColor = if (valid) {
|
val iconColor = if (valid) {
|
||||||
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else HighOrLowlight
|
if (showStrength && key.value.isNotEmpty()) PassphraseStrength.check(key.value).color else MaterialTheme.colors.secondary
|
||||||
} else Color.Red
|
} else Color.Red
|
||||||
val keyboard = LocalSoftwareKeyboardController.current
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
val keyboardOptions = KeyboardOptions(
|
val keyboardOptions = KeyboardOptions(
|
||||||
@@ -420,7 +417,7 @@ fun DatabaseKeyField(
|
|||||||
TextFieldDefaults.TextFieldDecorationBox(
|
TextFieldDefaults.TextFieldDecorationBox(
|
||||||
value = state.value.text,
|
value = state.value.text,
|
||||||
innerTextField = innerTextField,
|
innerTextField = innerTextField,
|
||||||
placeholder = { Text(placeholder, color = HighOrLowlight) },
|
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
isError = !valid,
|
isError = !valid,
|
||||||
@@ -436,6 +433,13 @@ fun DatabaseKeyField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
snapshotFlow { dependsOn?.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(state.value.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
// based on https://generatepasswords.org/how-to-calculate-entropy/
|
||||||
@@ -461,7 +465,7 @@ private fun passphraseEntropy(s: String): Double {
|
|||||||
return s.length * log2(poolSize.toDouble())
|
return s.length * log2(poolSize.toDouble())
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class PassphraseStrength(val color: Color) {
|
enum class PassphraseStrength(val color: Color) {
|
||||||
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
VERY_WEAK(Color.Red), WEAK(WarningOrange), REASONABLE(WarningYellow), STRONG(SimplexGreen);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -504,4 +508,4 @@ fun PreviewDatabaseEncryptionLayout() {
|
|||||||
onConfirmEncrypt = {},
|
onConfirmEncrypt = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-69
@@ -1,9 +1,11 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
|
import SectionBottomSpacer
|
||||||
import SectionSpacer
|
import SectionSpacer
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
@@ -13,6 +15,7 @@ import androidx.compose.runtime.*
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
@@ -20,6 +23,7 @@ import chat.simplex.app.R
|
|||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
import chat.simplex.app.views.helpers.*
|
import chat.simplex.app.views.helpers.*
|
||||||
|
import chat.simplex.app.views.usersettings.AppVersionText
|
||||||
import chat.simplex.app.views.usersettings.NotificationsMode
|
import chat.simplex.app.views.usersettings.NotificationsMode
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
@@ -35,94 +39,137 @@ fun DatabaseErrorView(
|
|||||||
) {
|
) {
|
||||||
val progressIndicator = remember { mutableStateOf(false) }
|
val progressIndicator = remember { mutableStateOf(false) }
|
||||||
val dbKey = remember { mutableStateOf("") }
|
val dbKey = remember { mutableStateOf("") }
|
||||||
var storedDBKey by remember { mutableStateOf(DatabaseUtils.getDatabaseKey()) }
|
var storedDBKey by remember { mutableStateOf(DatabaseUtils.ksDatabasePassword.get()) }
|
||||||
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
var useKeychain by remember { mutableStateOf(appPreferences.storeDBPassphrase.get()) }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
val restoreDbFromBackup = remember { mutableStateOf(shouldShowRestoreDbButton(appPreferences, context)) }
|
||||||
val saveAndRunChatOnClick: () -> Unit = {
|
|
||||||
DatabaseUtils.setDatabaseKey(dbKey.value)
|
fun callRunChat(confirmMigrations: MigrationConfirmation? = null) {
|
||||||
|
val useKey = if (useKeychain) null else dbKey.value
|
||||||
|
runChat(useKey, confirmMigrations, chatDbStatus, progressIndicator, appPreferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveAndRunChatOnClick() {
|
||||||
|
DatabaseUtils.ksDatabasePassword.set(dbKey.value)
|
||||||
storedDBKey = dbKey.value
|
storedDBKey = dbKey.value
|
||||||
appPreferences.storeDBPassphrase.set(true)
|
appPreferences.storeDBPassphrase.set(true)
|
||||||
useKeychain = true
|
useKeychain = true
|
||||||
appPreferences.initialRandomDBPassphrase.set(false)
|
appPreferences.initialRandomDBPassphrase.set(false)
|
||||||
runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
callRunChat()
|
||||||
}
|
}
|
||||||
val title = when (chatDbStatus.value) {
|
|
||||||
is DBMigrationResult.OK -> ""
|
@Composable
|
||||||
is DBMigrationResult.ErrorNotADatabase -> if (useKeychain && !storedDBKey.isNullOrEmpty())
|
fun DatabaseErrorDetails(@StringRes title: Int, content: @Composable ColumnScope.() -> Unit) {
|
||||||
generalGetString(R.string.wrong_passphrase)
|
Text(
|
||||||
else
|
generalGetString(title),
|
||||||
generalGetString(R.string.encrypted_database)
|
Modifier.padding(start = DEFAULT_PADDING, top = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
|
||||||
is DBMigrationResult.Error -> generalGetString(R.string.database_error)
|
style = MaterialTheme.typography.h1
|
||||||
is DBMigrationResult.ErrorKeychain -> generalGetString(R.string.keychain_error)
|
)
|
||||||
is DBMigrationResult.Unknown -> generalGetString(R.string.database_error)
|
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), content)
|
||||||
null -> "" // should never be here
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FileNameText(dbFile: String) {
|
||||||
|
Text(String.format(generalGetString(R.string.file_with_path), dbFile.split("/").lastOrNull() ?: dbFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MigrationsText(ms: List<String>) {
|
||||||
|
Text(String.format(generalGetString(R.string.database_migrations), ms.joinToString(", ")))
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
||||||
title,
|
when (val status = chatDbStatus.value) {
|
||||||
Modifier.padding(start = 16.dp, top = 16.dp, bottom = 24.dp),
|
is DBMigrationResult.ErrorNotADatabase ->
|
||||||
style = MaterialTheme.typography.h1
|
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
||||||
)
|
DatabaseErrorDetails(R.string.wrong_passphrase) {
|
||||||
SectionView(null, padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF)) {
|
|
||||||
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
|
|
||||||
when (val status = chatDbStatus.value) {
|
|
||||||
is DBMigrationResult.ErrorNotADatabase -> {
|
|
||||||
if (useKeychain && !storedDBKey.isNullOrEmpty()) {
|
|
||||||
Text(generalGetString(R.string.passphrase_is_different))
|
Text(generalGetString(R.string.passphrase_is_different))
|
||||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
DatabaseKeyField(dbKey, buttonEnabled) {
|
||||||
saveAndRunChatOnClick()
|
saveAndRunChatOnClick()
|
||||||
}
|
}
|
||||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
SectionSpacer()
|
SectionSpacer()
|
||||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
FileNameText(status.dbFile)
|
||||||
} else {
|
}
|
||||||
|
} else {
|
||||||
|
DatabaseErrorDetails(R.string.encrypted_database) {
|
||||||
Text(generalGetString(R.string.database_passphrase_is_required))
|
Text(generalGetString(R.string.database_passphrase_is_required))
|
||||||
DatabaseKeyField(dbKey, buttonEnabled) {
|
|
||||||
if (useKeychain) saveAndRunChatOnClick() else runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences)
|
|
||||||
}
|
|
||||||
if (useKeychain) {
|
if (useKeychain) {
|
||||||
SaveAndOpenButton(buttonEnabled, saveAndRunChatOnClick)
|
DatabaseKeyField(dbKey, buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
|
SaveAndOpenButton(buttonEnabled, ::saveAndRunChatOnClick)
|
||||||
} else {
|
} else {
|
||||||
OpenChatButton(buttonEnabled) { runChat(dbKey.value, chatDbStatus, progressIndicator, appPreferences) }
|
DatabaseKeyField(dbKey, buttonEnabled) { callRunChat() }
|
||||||
|
OpenChatButton(buttonEnabled) { callRunChat() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DBMigrationResult.Error -> {
|
is DBMigrationResult.ErrorMigration -> when (val err = status.migrationError) {
|
||||||
Text(String.format(generalGetString(R.string.file_with_path), status.dbFile))
|
is MigrationError.Upgrade ->
|
||||||
Text(String.format(generalGetString(R.string.error_with_info), status.migrationError))
|
DatabaseErrorDetails(R.string.database_upgrade) {
|
||||||
|
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUp) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||||
|
Text(generalGetString(R.string.upgrade_and_open_chat))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
MigrationsText(err.upMigrations.map { it.upName })
|
||||||
|
AppVersionText()
|
||||||
|
}
|
||||||
|
is MigrationError.Downgrade ->
|
||||||
|
DatabaseErrorDetails(R.string.database_downgrade) {
|
||||||
|
TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) {
|
||||||
|
Text(generalGetString(R.string.downgrade_and_open_chat))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(generalGetString(R.string.database_downgrade_warning), fontWeight = FontWeight.Bold)
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
MigrationsText(err.downMigrations)
|
||||||
|
AppVersionText()
|
||||||
|
}
|
||||||
|
is MigrationError.Error ->
|
||||||
|
DatabaseErrorDetails(R.string.incompatible_database_version) {
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
Text(String.format(generalGetString(R.string.error_with_info), mtrErrorDescription(err.mtrError)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DBMigrationResult.ErrorSQL ->
|
||||||
|
DatabaseErrorDetails(R.string.database_error) {
|
||||||
|
FileNameText(status.dbFile)
|
||||||
|
Text(String.format(generalGetString(R.string.error_with_info), status.migrationSQLError))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.ErrorKeychain -> {
|
is DBMigrationResult.ErrorKeychain ->
|
||||||
|
DatabaseErrorDetails(R.string.keychain_error) {
|
||||||
Text(generalGetString(R.string.cannot_access_keychain))
|
Text(generalGetString(R.string.cannot_access_keychain))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.Unknown -> {
|
is DBMigrationResult.InvalidConfirmation ->
|
||||||
|
DatabaseErrorDetails(R.string.invalid_migration_confirmation) {
|
||||||
|
// this can only happen if incorrect parameter is passed
|
||||||
|
}
|
||||||
|
is DBMigrationResult.Unknown ->
|
||||||
|
DatabaseErrorDetails(R.string.database_error) {
|
||||||
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
Text(String.format(generalGetString(R.string.unknown_database_error_with_info), status.json))
|
||||||
}
|
}
|
||||||
is DBMigrationResult.OK -> {
|
is DBMigrationResult.OK -> {}
|
||||||
}
|
null -> {}
|
||||||
null -> {
|
}
|
||||||
}
|
if (restoreDbFromBackup.value) {
|
||||||
}
|
SectionSpacer()
|
||||||
if (restoreDbFromBackup.value) {
|
Text(generalGetString(R.string.database_backup_can_be_restored))
|
||||||
SectionSpacer()
|
Spacer(Modifier.size(DEFAULT_PADDING))
|
||||||
Text(generalGetString(R.string.database_backup_can_be_restored))
|
RestoreDbButton {
|
||||||
Spacer(Modifier.size(16.dp))
|
AlertManager.shared.showAlertDialog(
|
||||||
RestoreDbButton {
|
title = generalGetString(R.string.restore_database_alert_title),
|
||||||
AlertManager.shared.showAlertDialog(
|
text = generalGetString(R.string.restore_database_alert_desc),
|
||||||
title = generalGetString(R.string.restore_database_alert_title),
|
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
||||||
text = generalGetString(R.string.restore_database_alert_desc),
|
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
||||||
confirmText = generalGetString(R.string.restore_database_alert_confirm),
|
destructive = true,
|
||||||
onConfirm = { restoreDb(restoreDbFromBackup, appPreferences, context) },
|
)
|
||||||
destructive = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SectionBottomSpacer()
|
||||||
}
|
}
|
||||||
if (progressIndicator.value) {
|
if (progressIndicator.value) {
|
||||||
Box(
|
Box(
|
||||||
@@ -133,7 +180,7 @@ fun DatabaseErrorView(
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -141,7 +188,8 @@ fun DatabaseErrorView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun runChat(
|
private fun runChat(
|
||||||
dbKey: String,
|
dbKey: String? = null,
|
||||||
|
confirmMigrations: MigrationConfirmation? = null,
|
||||||
chatDbStatus: State<DBMigrationResult?>,
|
chatDbStatus: State<DBMigrationResult?>,
|
||||||
progressIndicator: MutableState<Boolean>,
|
progressIndicator: MutableState<Boolean>,
|
||||||
prefs: AppPreferences
|
prefs: AppPreferences
|
||||||
@@ -150,7 +198,7 @@ private fun runChat(
|
|||||||
if (progressIndicator.value) return@launch
|
if (progressIndicator.value) return@launch
|
||||||
progressIndicator.value = true
|
progressIndicator.value = true
|
||||||
try {
|
try {
|
||||||
SimplexApp.context.initChatController(dbKey)
|
SimplexApp.context.initChatController(dbKey, confirmMigrations)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
Log.d(TAG, "initializeChat ${e.stackTraceToString()}")
|
||||||
}
|
}
|
||||||
@@ -163,18 +211,17 @@ private fun runChat(
|
|||||||
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
NotificationsMode.PERIODIC.name -> SimplexApp.context.schedulePeriodicWakeUp()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is DBMigrationResult.ErrorNotADatabase -> {
|
is DBMigrationResult.ErrorNotADatabase ->
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.wrong_passphrase_title), generalGetString(R.string.enter_correct_passphrase))
|
||||||
}
|
is DBMigrationResult.ErrorSQL ->
|
||||||
is DBMigrationResult.Error -> {
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationSQLError)
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.database_error), status.migrationError)
|
is DBMigrationResult.ErrorKeychain ->
|
||||||
}
|
|
||||||
is DBMigrationResult.ErrorKeychain -> {
|
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.keychain_error))
|
||||||
}
|
is DBMigrationResult.Unknown ->
|
||||||
is DBMigrationResult.Unknown -> {
|
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), status.json)
|
||||||
}
|
is DBMigrationResult.InvalidConfirmation ->
|
||||||
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.invalid_migration_confirmation))
|
||||||
|
is DBMigrationResult.ErrorMigration -> {}
|
||||||
null -> {}
|
null -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,9 +251,17 @@ private fun restoreDb(restoreDbFromBackup: MutableState<Boolean>, prefs: AppPref
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mtrErrorDescription(err: MTRError): String =
|
||||||
|
when (err) {
|
||||||
|
is MTRError.NoDown ->
|
||||||
|
String.format(generalGetString(R.string.mtr_error_no_down_migration), err.dbMigrations.joinToString(", "))
|
||||||
|
is MTRError.Different ->
|
||||||
|
String.format(generalGetString(R.string.mtr_error_different), err.appMigration, err.dbMigration)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
private fun DatabaseKeyField(text: MutableState<String>, enabled: Boolean, onClick: (() -> Unit)? = null) {
|
||||||
DatabaseKeyField(
|
PassphraseField(
|
||||||
text,
|
text,
|
||||||
generalGetString(R.string.enter_passphrase),
|
generalGetString(R.string.enter_passphrase),
|
||||||
isValid = ::validKey,
|
isValid = ::validKey,
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package chat.simplex.app.views.database
|
package chat.simplex.app.views.database
|
||||||
|
|
||||||
import SectionDivider
|
import SectionBottomSpacer
|
||||||
|
import SectionDividerSpaced
|
||||||
import SectionTextFooter
|
import SectionTextFooter
|
||||||
import SectionItemView
|
import SectionItemView
|
||||||
import SectionSpacer
|
|
||||||
import SectionView
|
import SectionView
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.FileUtils
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||||
@@ -18,15 +17,15 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.*
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
@@ -38,6 +37,7 @@ import chat.simplex.app.views.helpers.*
|
|||||||
import chat.simplex.app.views.usersettings.*
|
import chat.simplex.app.views.usersettings.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.datetime.*
|
import kotlinx.datetime.*
|
||||||
|
import org.apache.commons.io.IOUtils
|
||||||
import java.io.*
|
import java.io.*
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
@@ -50,7 +50,7 @@ fun DatabaseView(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val progressIndicator = remember { mutableStateOf(false) }
|
val progressIndicator = remember { mutableStateOf(false) }
|
||||||
val runChat = remember { mutableStateOf(m.chatRunning.value ?: true) }
|
val runChat = remember { m.chatRunning }
|
||||||
val prefs = m.controller.appPrefs
|
val prefs = m.controller.appPrefs
|
||||||
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
val useKeychain = remember { mutableStateOf(prefs.storeDBPassphrase.get()) }
|
||||||
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
val chatArchiveName = remember { mutableStateOf(prefs.chatArchiveName.get()) }
|
||||||
@@ -74,7 +74,7 @@ fun DatabaseView(
|
|||||||
) {
|
) {
|
||||||
DatabaseLayout(
|
DatabaseLayout(
|
||||||
progressIndicator.value,
|
progressIndicator.value,
|
||||||
runChat.value,
|
runChat.value != false,
|
||||||
m.chatDbChanged.value,
|
m.chatDbChanged.value,
|
||||||
useKeychain.value,
|
useKeychain.value,
|
||||||
m.chatDbEncrypted.value,
|
m.chatDbEncrypted.value,
|
||||||
@@ -84,8 +84,11 @@ fun DatabaseView(
|
|||||||
chatArchiveTime,
|
chatArchiveTime,
|
||||||
chatLastStart,
|
chatLastStart,
|
||||||
chatDbDeleted.value,
|
chatDbDeleted.value,
|
||||||
|
m.controller.appPrefs.privacyFullBackup,
|
||||||
appFilesCountAndSize,
|
appFilesCountAndSize,
|
||||||
chatItemTTL,
|
chatItemTTL,
|
||||||
|
m.currentUser.value,
|
||||||
|
m.users,
|
||||||
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
startChat = { startChat(m, runChat, chatLastStart, m.chatDbChanged) },
|
||||||
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
stopChatAlert = { stopChatAlert(m, runChat, context) },
|
||||||
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
exportArchive = { exportArchive(context, m, progressIndicator, chatArchiveName, chatArchiveTime, chatArchiveFile, saveArchiveLauncher) },
|
||||||
@@ -111,7 +114,7 @@ fun DatabaseView(
|
|||||||
Modifier
|
Modifier
|
||||||
.padding(horizontal = 2.dp)
|
.padding(horizontal = 2.dp)
|
||||||
.size(30.dp),
|
.size(30.dp),
|
||||||
color = HighOrLowlight,
|
color = MaterialTheme.colors.secondary,
|
||||||
strokeWidth = 2.5.dp
|
strokeWidth = 2.5.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -126,14 +129,17 @@ fun DatabaseLayout(
|
|||||||
chatDbChanged: Boolean,
|
chatDbChanged: Boolean,
|
||||||
useKeyChain: Boolean,
|
useKeyChain: Boolean,
|
||||||
chatDbEncrypted: Boolean?,
|
chatDbEncrypted: Boolean?,
|
||||||
initialRandomDBPassphrase: Preference<Boolean>,
|
initialRandomDBPassphrase: SharedPreference<Boolean>,
|
||||||
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
importArchiveLauncher: ManagedActivityResultLauncher<String, Uri?>,
|
||||||
chatArchiveName: MutableState<String?>,
|
chatArchiveName: MutableState<String?>,
|
||||||
chatArchiveTime: MutableState<Instant?>,
|
chatArchiveTime: MutableState<Instant?>,
|
||||||
chatLastStart: MutableState<Instant?>,
|
chatLastStart: MutableState<Instant?>,
|
||||||
chatDbDeleted: Boolean,
|
chatDbDeleted: Boolean,
|
||||||
|
privacyFullBackup: SharedPreference<Boolean>,
|
||||||
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
appFilesCountAndSize: MutableState<Pair<Int, Long>>,
|
||||||
chatItemTTL: MutableState<ChatItemTTL>,
|
chatItemTTL: MutableState<ChatItemTTL>,
|
||||||
|
currentUser: User?,
|
||||||
|
users: List<UserInfo>,
|
||||||
startChat: () -> Unit,
|
startChat: () -> Unit,
|
||||||
stopChatAlert: () -> Unit,
|
stopChatAlert: () -> Unit,
|
||||||
exportArchive: () -> Unit,
|
exportArchive: () -> Unit,
|
||||||
@@ -147,26 +153,45 @@ fun DatabaseLayout(
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()),
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
) {
|
) {
|
||||||
AppBarTitle(stringResource(R.string.your_chat_database))
|
AppBarTitle(stringResource(R.string.your_chat_database))
|
||||||
|
|
||||||
|
SectionView(stringResource(R.string.messages_section_title).uppercase()) {
|
||||||
|
TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected)
|
||||||
|
}
|
||||||
|
SectionTextFooter(
|
||||||
|
remember(currentUser?.displayName) {
|
||||||
|
buildAnnotatedString {
|
||||||
|
append(generalGetString(R.string.messages_section_description) + " ")
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(currentUser?.displayName ?: "")
|
||||||
|
}
|
||||||
|
append(".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(stringResource(R.string.run_chat_section)) {
|
SectionView(stringResource(R.string.run_chat_section)) {
|
||||||
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
RunChatSetting(runChat, stopped, chatDbDeleted, startChat, stopChatAlert)
|
||||||
}
|
}
|
||||||
SectionSpacer()
|
SectionDividerSpaced()
|
||||||
|
|
||||||
SectionView(stringResource(R.string.chat_database_section)) {
|
SectionView(stringResource(R.string.chat_database_section)) {
|
||||||
val unencrypted = chatDbEncrypted == false
|
val unencrypted = chatDbEncrypted == false
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
if (unencrypted) Icons.Outlined.LockOpen else if (useKeyChain) Icons.Filled.VpnKey else Icons.Outlined.Lock,
|
if (unencrypted) painterResource(R.drawable.ic_lock_open) else if (useKeyChain) painterResource(R.drawable.ic_vpn_key_filled)
|
||||||
|
else painterResource(R
|
||||||
|
.drawable.ic_lock),
|
||||||
stringResource(R.string.database_passphrase),
|
stringResource(R.string.database_passphrase),
|
||||||
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
click = showSettingsModal() { DatabaseEncryptionView(it) },
|
||||||
iconColor = if (unencrypted) WarningOrange else HighOrLowlight,
|
iconColor = if (unencrypted) WarningOrange else MaterialTheme.colors.secondary,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
AppDataBackupPreference(privacyFullBackup, initialRandomDBPassphrase)
|
||||||
|
SectionDividerSpaced(maxBottomPadding = false)
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.IosShare,
|
painterResource(R.drawable.ic_ios_share),
|
||||||
stringResource(R.string.export_database),
|
stringResource(R.string.export_database),
|
||||||
click = {
|
click = {
|
||||||
if (initialRandomDBPassphrase.get()) {
|
if (initialRandomDBPassphrase.get()) {
|
||||||
@@ -179,31 +204,28 @@ fun DatabaseLayout(
|
|||||||
iconColor = MaterialTheme.colors.primary,
|
iconColor = MaterialTheme.colors.primary,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.FileDownload,
|
painterResource(R.drawable.ic_download),
|
||||||
stringResource(R.string.import_database),
|
stringResource(R.string.import_database),
|
||||||
{ importArchiveLauncher.launch("application/zip") },
|
{ importArchiveLauncher.launch("application/zip") },
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
iconColor = Color.Red,
|
iconColor = Color.Red,
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
val chatArchiveNameVal = chatArchiveName.value
|
val chatArchiveNameVal = chatArchiveName.value
|
||||||
val chatArchiveTimeVal = chatArchiveTime.value
|
val chatArchiveTimeVal = chatArchiveTime.value
|
||||||
val chatLastStartVal = chatLastStart.value
|
val chatLastStartVal = chatLastStart.value
|
||||||
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
if (chatArchiveNameVal != null && chatArchiveTimeVal != null && chatLastStartVal != null) {
|
||||||
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
val title = chatArchiveTitle(chatArchiveTimeVal, chatLastStartVal)
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.Inventory2,
|
painterResource(R.drawable.ic_inventory_2),
|
||||||
title,
|
title,
|
||||||
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
click = showSettingsModal { ChatArchiveView(it, title, chatArchiveNameVal, chatArchiveTimeVal) },
|
||||||
disabled = operationsDisabled
|
disabled = operationsDisabled
|
||||||
)
|
)
|
||||||
SectionDivider()
|
|
||||||
}
|
}
|
||||||
SettingsActionItem(
|
SettingsActionItem(
|
||||||
Icons.Outlined.DeleteForever,
|
painterResource(R.drawable.ic_delete_forever),
|
||||||
stringResource(R.string.delete_database),
|
stringResource(R.string.delete_database),
|
||||||
deleteChatAlert,
|
deleteChatAlert,
|
||||||
textColor = Color.Red,
|
textColor = Color.Red,
|
||||||
@@ -218,19 +240,17 @@ fun DatabaseLayout(
|
|||||||
stringResource(R.string.stop_chat_to_enable_database_actions)
|
stringResource(R.string.stop_chat_to_enable_database_actions)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
SectionSpacer()
|
SectionDividerSpaced(maxTopPadding = true)
|
||||||
|
|
||||||
SectionView(stringResource(R.string.data_section)) {
|
SectionView(stringResource(R.string.files_and_media_section).uppercase()) {
|
||||||
SectionItemView { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!progressIndicator && !chatDbChanged), onChatItemTTLSelected) }
|
|
||||||
SectionDivider()
|
|
||||||
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0
|
||||||
SectionItemView(
|
SectionItemView(
|
||||||
deleteAppFilesAndMedia,
|
deleteAppFilesAndMedia,
|
||||||
disabled = deleteFilesDisabled
|
disabled = deleteFilesDisabled
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.delete_files_and_media),
|
stringResource(if (users.size > 1) R.string.delete_files_and_media_for_all_users else R.string.delete_files_and_media_all),
|
||||||
color = if (deleteFilesDisabled) HighOrLowlight else Color.Red
|
color = if (deleteFilesDisabled) MaterialTheme.colors.secondary else Color.Red
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +262,24 @@ fun DatabaseLayout(
|
|||||||
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
String.format(stringResource(R.string.total_files_count_and_size), count, formatBytes(size))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
SectionBottomSpacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppDataBackupPreference(privacyFullBackup: SharedPreference<Boolean>, initialRandomDBPassphrase: SharedPreference<Boolean>) {
|
||||||
|
SettingsPreferenceItem(
|
||||||
|
painterResource(R.drawable.ic_backup),
|
||||||
|
iconColor = MaterialTheme.colors.secondary,
|
||||||
|
pref = privacyFullBackup,
|
||||||
|
text = stringResource(R.string.full_backup)
|
||||||
|
) {
|
||||||
|
if (initialRandomDBPassphrase.get()) {
|
||||||
|
exportProhibitedAlert()
|
||||||
|
privacyFullBackup.set(false)
|
||||||
|
} else {
|
||||||
|
privacyFullBackup.set(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +294,8 @@ private fun setChatItemTTLAlert(
|
|||||||
text = generalGetString(R.string.enable_automatic_deletion_message),
|
text = generalGetString(R.string.enable_automatic_deletion_message),
|
||||||
confirmText = generalGetString(R.string.delete_messages),
|
confirmText = generalGetString(R.string.delete_messages),
|
||||||
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
|
onConfirm = { setCiTTL(m, selectedChatItemTTL, progressIndicator, appFilesCountAndSize, context) },
|
||||||
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value }
|
onDismiss = { selectedChatItemTTL.value = m.chatItemTTL.value },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,36 +334,23 @@ fun RunChatSetting(
|
|||||||
startChat: () -> Unit,
|
startChat: () -> Unit,
|
||||||
stopChatAlert: () -> Unit
|
stopChatAlert: () -> Unit
|
||||||
) {
|
) {
|
||||||
SectionItemView() {
|
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
SettingsActionItemWithContent(
|
||||||
val chatRunningText = if (stopped) stringResource(R.string.chat_is_stopped) else stringResource(R.string.chat_is_running)
|
icon = if (stopped) painterResource(R.drawable.ic_report_filled) else painterResource(R.drawable.ic_play_arrow_filled),
|
||||||
Icon(
|
text = chatRunningText,
|
||||||
if (stopped) Icons.Filled.Report else Icons.Filled.PlayArrow,
|
iconColor = if (stopped) Color.Red else MaterialTheme.colors.primary,
|
||||||
chatRunningText,
|
) {
|
||||||
tint = if (stopped) Color.Red else MaterialTheme.colors.primary
|
DefaultSwitch(
|
||||||
)
|
enabled = !chatDbDeleted,
|
||||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
checked = runChat,
|
||||||
Text(
|
onCheckedChange = { runChatSwitch ->
|
||||||
chatRunningText,
|
if (runChatSwitch) {
|
||||||
Modifier.padding(end = 24.dp)
|
startChat()
|
||||||
)
|
} else {
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
stopChatAlert()
|
||||||
Switch(
|
}
|
||||||
enabled = !chatDbDeleted,
|
},
|
||||||
checked = runChat,
|
)
|
||||||
onCheckedChange = { runChatSwitch ->
|
|
||||||
if (runChatSwitch) {
|
|
||||||
startChat()
|
|
||||||
} else {
|
|
||||||
stopChatAlert()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colors.primary,
|
|
||||||
uncheckedThumbColor = HighOrLowlight
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +359,7 @@ fun chatArchiveTitle(chatArchiveTime: Instant, chatLastStart: Instant): String {
|
|||||||
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
return stringResource(if (chatArchiveTime < chatLastStart) R.string.old_database_archive else R.string.new_database_archive)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
private fun startChat(m: ChatModel, runChat: MutableState<Boolean?>, chatLastStart: MutableState<Instant?>, chatDbChanged: MutableState<Boolean>) {
|
||||||
withApi {
|
withApi {
|
||||||
try {
|
try {
|
||||||
if (chatDbChanged.value) {
|
if (chatDbChanged.value) {
|
||||||
@@ -362,7 +388,7 @@ private fun startChat(m: ChatModel, runChat: MutableState<Boolean>, chatLastStar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun stopChatAlert(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
AlertManager.shared.showAlertDialog(
|
AlertManager.shared.showAlertDialog(
|
||||||
title = generalGetString(R.string.stop_chat_question),
|
title = generalGetString(R.string.stop_chat_question),
|
||||||
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
text = generalGetString(R.string.stop_chat_to_export_import_or_delete_chat_database),
|
||||||
@@ -379,7 +405,7 @@ private fun exportProhibitedAlert() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
if (m.controller.appPrefs.performLA.get()) {
|
if (m.controller.appPrefs.performLA.get()) {
|
||||||
authenticate(
|
authenticate(
|
||||||
generalGetString(R.string.auth_stop_chat),
|
generalGetString(R.string.auth_stop_chat),
|
||||||
@@ -387,12 +413,13 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
|||||||
context as FragmentActivity,
|
context as FragmentActivity,
|
||||||
completed = { laResult ->
|
completed = { laResult ->
|
||||||
when (laResult) {
|
when (laResult) {
|
||||||
LAResult.Success, LAResult.Unavailable -> {
|
LAResult.Success, is LAResult.Unavailable -> {
|
||||||
stopChat(m, runChat, context)
|
stopChat(m, runChat, context)
|
||||||
}
|
}
|
||||||
is LAResult.Error -> {
|
is LAResult.Error -> {
|
||||||
|
runChat.value = true
|
||||||
}
|
}
|
||||||
LAResult.Failed -> {
|
is LAResult.Failed -> {
|
||||||
runChat.value = true
|
runChat.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,13 +430,13 @@ private fun authStopChat(m: ChatModel, runChat: MutableState<Boolean>, context:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean>, context: Context) {
|
private fun stopChat(m: ChatModel, runChat: MutableState<Boolean?>, context: Context) {
|
||||||
withApi {
|
withApi {
|
||||||
try {
|
try {
|
||||||
m.controller.apiStopChat()
|
m.controller.apiStopChat()
|
||||||
runChat.value = false
|
runChat.value = false
|
||||||
m.chatRunning.value = false
|
m.chatRunning.value = false
|
||||||
SimplexService.stop(context)
|
SimplexService.safeStopService(context)
|
||||||
MessagesFetcherWorker.cancelAll()
|
MessagesFetcherWorker.cancelAll()
|
||||||
} catch (e: Error) {
|
} catch (e: Error) {
|
||||||
runChat.value = true
|
runChat.value = true
|
||||||
@@ -517,7 +544,8 @@ private fun importArchiveAlert(
|
|||||||
title = generalGetString(R.string.import_database_question),
|
title = generalGetString(R.string.import_database_question),
|
||||||
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
text = generalGetString(R.string.your_current_chat_database_will_be_deleted_and_replaced_with_the_imported_one),
|
||||||
confirmText = generalGetString(R.string.import_database_confirmation),
|
confirmText = generalGetString(R.string.import_database_confirmation),
|
||||||
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) }
|
onConfirm = { importArchive(m, context, importedArchiveUri, appFilesCountAndSize, progressIndicator) },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +565,7 @@ private fun importArchive(
|
|||||||
try {
|
try {
|
||||||
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
val config = ArchiveConfig(archivePath, parentTempDirectory = context.cacheDir.toString())
|
||||||
m.controller.apiImportArchive(config)
|
m.controller.apiImportArchive(config)
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
appFilesCountAndSize.value = directoryFileCountAndSize(getAppFilesDirectory(context))
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_imported), generalGetString(R.string.restart_the_app_to_use_imported_chat_database))
|
||||||
@@ -565,7 +593,7 @@ private fun saveArchiveFromUri(context: Context, importedArchiveUri: Uri): Strin
|
|||||||
if (inputStream != null && archiveName != null) {
|
if (inputStream != null && archiveName != null) {
|
||||||
val archivePath = "${context.cacheDir}/$archiveName"
|
val archivePath = "${context.cacheDir}/$archiveName"
|
||||||
val destFile = File(archivePath)
|
val destFile = File(archivePath)
|
||||||
FileUtils.copy(inputStream, FileOutputStream(destFile))
|
IOUtils.copy(inputStream, FileOutputStream(destFile))
|
||||||
archivePath
|
archivePath
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
Log.e(TAG, "saveArchiveFromUri null inputStream")
|
||||||
@@ -582,7 +610,8 @@ private fun deleteChatAlert(m: ChatModel, progressIndicator: MutableState<Boolea
|
|||||||
title = generalGetString(R.string.delete_chat_profile_question),
|
title = generalGetString(R.string.delete_chat_profile_question),
|
||||||
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
text = generalGetString(R.string.delete_chat_profile_action_cannot_be_undone_warning),
|
||||||
confirmText = generalGetString(R.string.delete_verb),
|
confirmText = generalGetString(R.string.delete_verb),
|
||||||
onConfirm = { deleteChat(m, progressIndicator) }
|
onConfirm = { deleteChat(m, progressIndicator) },
|
||||||
|
destructive = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +621,7 @@ private fun deleteChat(m: ChatModel, progressIndicator: MutableState<Boolean>) {
|
|||||||
try {
|
try {
|
||||||
m.controller.apiDeleteStorage()
|
m.controller.apiDeleteStorage()
|
||||||
m.chatDbDeleted.value = true
|
m.chatDbDeleted.value = true
|
||||||
DatabaseUtils.removeDatabaseKey()
|
DatabaseUtils.ksDatabasePassword.remove()
|
||||||
m.controller.appPrefs.storeDBPassphrase.set(true)
|
m.controller.appPrefs.storeDBPassphrase.set(true)
|
||||||
operationEnded(m, progressIndicator) {
|
operationEnded(m, progressIndicator) {
|
||||||
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
AlertManager.shared.showAlertMsg(generalGetString(R.string.chat_database_deleted), generalGetString(R.string.restart_the_app_to_create_a_new_chat_profile))
|
||||||
@@ -683,14 +712,17 @@ fun PreviewDatabaseLayout() {
|
|||||||
chatDbChanged = false,
|
chatDbChanged = false,
|
||||||
useKeyChain = false,
|
useKeyChain = false,
|
||||||
chatDbEncrypted = false,
|
chatDbEncrypted = false,
|
||||||
initialRandomDBPassphrase = Preference({ true }, {}),
|
initialRandomDBPassphrase = SharedPreference({ true }, {}),
|
||||||
importArchiveLauncher = rememberGetContentLauncher {},
|
importArchiveLauncher = rememberGetContentLauncher {},
|
||||||
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
chatArchiveName = remember { mutableStateOf("dummy_archive") },
|
||||||
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
chatArchiveTime = remember { mutableStateOf(Clock.System.now()) },
|
||||||
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
chatLastStart = remember { mutableStateOf(Clock.System.now()) },
|
||||||
chatDbDeleted = false,
|
chatDbDeleted = false,
|
||||||
|
privacyFullBackup = SharedPreference({ true }, {}),
|
||||||
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
appFilesCountAndSize = remember { mutableStateOf(0 to 0L) },
|
||||||
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
chatItemTTL = remember { mutableStateOf(ChatItemTTL.None) },
|
||||||
|
currentUser = User.sampleData,
|
||||||
|
users = listOf(UserInfo.sampleData),
|
||||||
startChat = {},
|
startChat = {},
|
||||||
stopChatAlert = {},
|
stopChatAlert = {},
|
||||||
exportArchive = {},
|
exportArchive = {},
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.*
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.TAG
|
import chat.simplex.app.TAG
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
class AlertManager {
|
class AlertManager {
|
||||||
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
var alertViews = mutableStateListOf<(@Composable () -> Unit)>()
|
||||||
@@ -24,17 +35,46 @@ class AlertManager {
|
|||||||
text: String? = null,
|
text: String? = null,
|
||||||
buttons: @Composable () -> Unit,
|
buttons: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = this::hideAlert,
|
onDismissRequest = this::hideAlert,
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
buttons = buttons
|
buttons = buttons,
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showAlertDialogButtonsColumn(
|
||||||
|
title: String,
|
||||||
|
text: AnnotatedString? = null,
|
||||||
|
buttons: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
showAlert {
|
||||||
|
Dialog(onDismissRequest = this::hideAlert) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
.padding(bottom = DEFAULT_PADDING)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.high) {
|
||||||
|
if (text != null) {
|
||||||
|
Text(text, Modifier.fillMaxWidth().padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING * 1.5f), fontSize = 16.sp, textAlign = TextAlign.Center, color = MaterialTheme.colors.secondary)
|
||||||
|
}
|
||||||
|
buttons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun showAlertDialog(
|
fun showAlertDialog(
|
||||||
title: String,
|
title: String,
|
||||||
text: String? = null,
|
text: String? = null,
|
||||||
@@ -45,54 +85,94 @@ class AlertManager {
|
|||||||
onDismissRequest: (() -> Unit)? = null,
|
onDismissRequest: (() -> Unit)? = null,
|
||||||
destructive: Boolean = false
|
destructive: Boolean = false
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
confirmButton = {
|
buttons = {
|
||||||
TextButton(onClick = {
|
Row (
|
||||||
onConfirm?.invoke()
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||||
hideAlert()
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(dismissText) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
onConfirm?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = if (destructive) MaterialTheme.colors.error else Color.Unspecified) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
TextButton(onClick = {
|
)
|
||||||
onDismiss?.invoke()
|
}
|
||||||
hideAlert()
|
}
|
||||||
}) { Text(dismissText) }
|
|
||||||
}
|
fun showAlertDialogStacked(
|
||||||
|
title: String,
|
||||||
|
text: String? = null,
|
||||||
|
confirmText: String = generalGetString(R.string.ok),
|
||||||
|
onConfirm: (() -> Unit)? = null,
|
||||||
|
dismissText: String = generalGetString(R.string.cancel_verb),
|
||||||
|
onDismiss: (() -> Unit)? = null,
|
||||||
|
onDismissRequest: (() -> Unit)? = null,
|
||||||
|
destructive: Boolean = false
|
||||||
|
) {
|
||||||
|
showAlert {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||||
|
title = alertTitle(title),
|
||||||
|
text = alertText(text),
|
||||||
|
buttons = {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING_HALF).padding(top = DEFAULT_PADDING, bottom = 2.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(dismissText) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
onConfirm?.invoke()
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = if (destructive) Color.Red else Color.Unspecified, textAlign = TextAlign.End) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAlertMsg(
|
fun showAlertMsg(
|
||||||
title: String, text: String? = null,
|
title: String, text: String? = null,
|
||||||
confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null
|
confirmText: String = generalGetString(R.string.ok)
|
||||||
) {
|
) {
|
||||||
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
|
|
||||||
showAlert {
|
showAlert {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = this::hideAlert,
|
onDismissRequest = this::hideAlert,
|
||||||
title = { Text(title) },
|
title = alertTitle(title),
|
||||||
text = alertText,
|
text = alertText(text),
|
||||||
confirmButton = {
|
buttons = {
|
||||||
TextButton(onClick = {
|
Row(
|
||||||
onConfirm?.invoke()
|
Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING_HALF),
|
||||||
hideAlert()
|
horizontalArrangement = Arrangement.Center
|
||||||
}) { Text(confirmText) }
|
) {
|
||||||
}
|
TextButton(onClick = {
|
||||||
|
hideAlert()
|
||||||
|
}) { Text(confirmText, color = Color.Unspecified) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAlertMsg(
|
fun showAlertMsg(
|
||||||
title: Int,
|
title: Int,
|
||||||
text: Int? = null,
|
text: Int? = null,
|
||||||
confirmText: Int = R.string.ok,
|
confirmText: Int = R.string.ok,
|
||||||
onConfirm: (() -> Unit)? = null
|
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText))
|
||||||
) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun showInView() {
|
fun showInView() {
|
||||||
@@ -102,4 +182,31 @@ class AlertManager {
|
|||||||
companion object {
|
companion object {
|
||||||
val shared = AlertManager()
|
val shared = AlertManager()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertTitle(title: String): (@Composable () -> Unit)? {
|
||||||
|
return {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 20.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alertText(text: String?): (@Composable () -> Unit)? {
|
||||||
|
return if (text == null) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
({
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,4 +2,8 @@ package chat.simplex.app.views.helpers
|
|||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
|
|
||||||
|
fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
|
||||||
|
|
||||||
|
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.Icon
|
import androidx.compose.material.Icon
|
||||||
import androidx.compose.material.MaterialTheme
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.*
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
@@ -23,18 +20,18 @@ import chat.simplex.app.model.ChatInfo
|
|||||||
import chat.simplex.app.ui.theme.SimpleXTheme
|
import chat.simplex.app.ui.theme.SimpleXTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
val icon =
|
val icon =
|
||||||
if (chatInfo is ChatInfo.Group) Icons.Filled.SupervisedUserCircle
|
if (chatInfo is ChatInfo.Group) R.drawable.ic_supervised_user_circle_filled
|
||||||
else Icons.Filled.AccountCircle
|
else R.drawable.ic_account_circle_filled
|
||||||
ProfileImage(size, chatInfo.image, icon, iconColor)
|
ProfileImage(size, chatInfo.image, icon, iconColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary) {
|
fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant) {
|
||||||
Box(Modifier.size(size)) {
|
Box(Modifier.size(size)) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Filled.TheaterComedy, stringResource(R.string.incognito),
|
painterResource(R.drawable.ic_theater_comedy_filled), stringResource(R.string.incognito),
|
||||||
modifier = Modifier.size(size).padding(size / 12),
|
modifier = Modifier.size(size).padding(size / 12),
|
||||||
iconColor
|
iconColor
|
||||||
)
|
)
|
||||||
@@ -45,17 +42,31 @@ fun IncognitoImage(size: Dp, iconColor: Color = MaterialTheme.colors.secondary)
|
|||||||
fun ProfileImage(
|
fun ProfileImage(
|
||||||
size: Dp,
|
size: Dp,
|
||||||
image: String? = null,
|
image: String? = null,
|
||||||
icon: ImageVector = Icons.Filled.AccountCircle,
|
icon: Int = R.drawable.ic_account_circle_filled,
|
||||||
color: Color = MaterialTheme.colors.secondary
|
color: Color = MaterialTheme.colors.secondaryVariant
|
||||||
) {
|
) {
|
||||||
Box(Modifier.size(size)) {
|
Box(Modifier.size(size)) {
|
||||||
if (image == null) {
|
if (image == null) {
|
||||||
Icon(
|
val iconToReplace = when (icon) {
|
||||||
icon,
|
R.drawable.ic_account_circle_filled -> AccountCircleFilled
|
||||||
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
R.drawable.ic_supervised_user_circle_filled -> SupervisedUserCircleFilled
|
||||||
tint = color,
|
else -> null
|
||||||
modifier = Modifier.fillMaxSize()
|
}
|
||||||
)
|
if (iconToReplace != null) {
|
||||||
|
Icon(
|
||||||
|
iconToReplace,
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(
|
||||||
|
painterResource(icon),
|
||||||
|
contentDescription = stringResource(R.string.icon_descr_profile_image_placeholder),
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
val imageBitmap = base64ToBitmap(image).asImageBitmap()
|
||||||
Image(
|
Image(
|
||||||
@@ -68,6 +79,7 @@ fun ProfileImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewChatInfoImage() {
|
fun PreviewChatInfoImage() {
|
||||||
|
|||||||
+18
-11
@@ -1,21 +1,24 @@
|
|||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.views.newchat.ActionButton
|
import chat.simplex.app.views.newchat.ActionButton
|
||||||
|
|
||||||
sealed class AttachmentOption {
|
sealed class AttachmentOption {
|
||||||
object TakePhoto: AttachmentOption()
|
object CameraPhoto: AttachmentOption()
|
||||||
object PickImage: AttachmentOption()
|
object GalleryImage: AttachmentOption()
|
||||||
object PickFile: AttachmentOption()
|
object GalleryVideo: AttachmentOption()
|
||||||
|
object File: AttachmentOption()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -37,16 +40,20 @@ fun ChooseAttachmentView(
|
|||||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
ActionButton(Modifier.fillMaxWidth(0.25f), null, stringResource(R.string.use_camera_button), icon = painterResource(R.drawable.ic_camera_enhance)) {
|
||||||
attachmentOption.value = AttachmentOption.TakePhoto
|
attachmentOption.value = AttachmentOption.CameraPhoto
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
ActionButton(Modifier.fillMaxWidth(0.33f), null, stringResource(R.string.gallery_image_button), icon = painterResource(R.drawable.ic_add_photo)) {
|
||||||
attachmentOption.value = AttachmentOption.PickImage
|
attachmentOption.value = AttachmentOption.GalleryImage
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) {
|
ActionButton(Modifier.fillMaxWidth(0.50f), null, stringResource(R.string.gallery_video_button), icon = painterResource(R.drawable.ic_smart_display)) {
|
||||||
attachmentOption.value = AttachmentOption.PickFile
|
attachmentOption.value = AttachmentOption.GalleryVideo
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
ActionButton(Modifier.fillMaxWidth(1f), null, stringResource(R.string.choose_file), icon = painterResource(R.drawable.ic_note_add)) {
|
||||||
|
attachmentOption.value = AttachmentOption.File
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,20 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CloseSheetBar(close: () -> Unit) {
|
fun CloseSheetBar(close: (() -> Unit)?, endButtons: @Composable RowScope.() -> Unit = {}) {
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -20,26 +25,40 @@ fun CloseSheetBar(close: () -> Unit) {
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.width(TitleInsetWithIcon - AppBarHorizontalPadding)
|
|
||||||
.padding(top = 4.dp), // Like in DefaultAppBar
|
.padding(top = 4.dp), // Like in DefaultAppBar
|
||||||
content = { NavigationButtonBack(close) }
|
content = {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
NavigationButtonBack(onButtonClicked = close)
|
||||||
|
Row {
|
||||||
|
endButtons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
fun AppBarTitle(title: String, withPadding: Boolean = true) {
|
||||||
val padding = if (withPadding)
|
val theme = CurrentColors.collectAsState()
|
||||||
PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING )
|
val titleColor = CurrentColors.collectAsState().value.appColors.title
|
||||||
else
|
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
|
||||||
PaddingValues(bottom = DEFAULT_PADDING)
|
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||||
|
else // color is not updated when changing themes if I pass null here
|
||||||
|
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(padding),
|
.padding(bottom = DEFAULT_PADDING * 1.5f, start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,),
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.h1
|
style = MaterialTheme.typography.h1.copy(brush = brush),
|
||||||
|
color = MaterialTheme.colors.primaryVariant,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.materialIcon
|
||||||
|
import androidx.compose.material.icons.materialPath
|
||||||
|
import androidx.compose.ui.graphics.vector.*
|
||||||
|
|
||||||
|
val AccountCircleFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_accountCircleFilled != null) {
|
||||||
|
return _accountCircleFilled!!
|
||||||
|
}
|
||||||
|
_accountCircleFilled = materialIcon(name = "Filled.AccountCircle") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(12.0f, 2.0f)
|
||||||
|
curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f)
|
||||||
|
reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f)
|
||||||
|
reflectiveCurveTo(17.52f, 2.0f, 12.0f, 2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 5.0f)
|
||||||
|
curveToRelative(1.66f, 0.0f, 3.0f, 1.34f, 3.0f, 3.0f)
|
||||||
|
reflectiveCurveToRelative(-1.34f, 3.0f, -3.0f, 3.0f)
|
||||||
|
reflectiveCurveToRelative(-3.0f, -1.34f, -3.0f, -3.0f)
|
||||||
|
reflectiveCurveToRelative(1.34f, -3.0f, 3.0f, -3.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 19.2f)
|
||||||
|
curveToRelative(-2.5f, 0.0f, -4.71f, -1.28f, -6.0f, -3.22f)
|
||||||
|
curveToRelative(0.03f, -1.99f, 4.0f, -3.08f, 6.0f, -3.08f)
|
||||||
|
curveToRelative(1.99f, 0.0f, 5.97f, 1.09f, 6.0f, 3.08f)
|
||||||
|
curveToRelative(-1.29f, 1.94f, -3.5f, 3.22f, -6.0f, 3.22f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _accountCircleFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _accountCircleFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val SupervisedUserCircleFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_supervisedUserCircleFilled != null) {
|
||||||
|
return _supervisedUserCircleFilled!!
|
||||||
|
}
|
||||||
|
_supervisedUserCircleFilled = materialIcon(name = "Filled.SupervisedUserCircle") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(11.99f, 2.0f)
|
||||||
|
curveToRelative(-5.52f, 0.0f, -10.0f, 4.48f, -10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(4.48f, 10.0f, 10.0f, 10.0f)
|
||||||
|
reflectiveCurveToRelative(10.0f, -4.48f, 10.0f, -10.0f)
|
||||||
|
reflectiveCurveToRelative(-4.48f, -10.0f, -10.0f, -10.0f)
|
||||||
|
close()
|
||||||
|
moveTo(15.6f, 8.34f)
|
||||||
|
curveToRelative(1.07f, 0.0f, 1.93f, 0.86f, 1.93f, 1.93f)
|
||||||
|
curveToRelative(0.0f, 1.07f, -0.86f, 1.93f, -1.93f, 1.93f)
|
||||||
|
curveToRelative(-1.07f, 0.0f, -1.93f, -0.86f, -1.93f, -1.93f)
|
||||||
|
curveToRelative(-0.01f, -1.07f, 0.86f, -1.93f, 1.93f, -1.93f)
|
||||||
|
close()
|
||||||
|
moveTo(9.6f, 6.76f)
|
||||||
|
curveToRelative(1.3f, 0.0f, 2.36f, 1.06f, 2.36f, 2.36f)
|
||||||
|
curveToRelative(0.0f, 1.3f, -1.06f, 2.36f, -2.36f, 2.36f)
|
||||||
|
reflectiveCurveToRelative(-2.36f, -1.06f, -2.36f, -2.36f)
|
||||||
|
curveToRelative(0.0f, -1.31f, 1.05f, -2.36f, 2.36f, -2.36f)
|
||||||
|
close()
|
||||||
|
moveTo(9.6f, 15.89f)
|
||||||
|
verticalLineToRelative(3.75f)
|
||||||
|
curveToRelative(-2.4f, -0.75f, -4.3f, -2.6f, -5.14f, -4.96f)
|
||||||
|
curveToRelative(1.05f, -1.12f, 3.67f, -1.69f, 5.14f, -1.69f)
|
||||||
|
curveToRelative(0.53f, 0.0f, 1.2f, 0.08f, 1.9f, 0.22f)
|
||||||
|
curveToRelative(-1.64f, 0.87f, -1.9f, 2.02f, -1.9f, 2.68f)
|
||||||
|
close()
|
||||||
|
moveTo(11.99f, 20.0f)
|
||||||
|
curveToRelative(-0.27f, 0.0f, -0.53f, -0.01f, -0.79f, -0.04f)
|
||||||
|
verticalLineToRelative(-4.07f)
|
||||||
|
curveToRelative(0.0f, -1.42f, 2.94f, -2.13f, 4.4f, -2.13f)
|
||||||
|
curveToRelative(1.07f, 0.0f, 2.92f, 0.39f, 3.84f, 1.15f)
|
||||||
|
curveToRelative(-1.17f, 2.97f, -4.06f, 5.09f, -7.45f, 5.09f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _supervisedUserCircleFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _supervisedUserCircleFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val BoltFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_boltFilled != null) {
|
||||||
|
return _boltFilled!!
|
||||||
|
}
|
||||||
|
_boltFilled = materialIcon(name = "Filled.Bolt") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(11.0f, 21.0f)
|
||||||
|
horizontalLineToRelative(-1.0f)
|
||||||
|
lineToRelative(1.0f, -7.0f)
|
||||||
|
horizontalLineTo(7.5f)
|
||||||
|
curveToRelative(-0.58f, 0.0f, -0.57f, -0.32f, -0.38f, -0.66f)
|
||||||
|
curveToRelative(0.19f, -0.34f, 0.05f, -0.08f, 0.07f, -0.12f)
|
||||||
|
curveTo(8.48f, 10.94f, 10.42f, 7.54f, 13.0f, 3.0f)
|
||||||
|
horizontalLineToRelative(1.0f)
|
||||||
|
lineToRelative(-1.0f, 7.0f)
|
||||||
|
horizontalLineToRelative(3.5f)
|
||||||
|
curveToRelative(0.49f, 0.0f, 0.56f, 0.33f, 0.47f, 0.51f)
|
||||||
|
lineToRelative(-0.07f, 0.15f)
|
||||||
|
curveTo(12.96f, 17.55f, 11.0f, 21.0f, 11.0f, 21.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _boltFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _boltFilled: ImageVector? = null
|
||||||
|
|
||||||
|
val MoreVertFilled: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_moreVertFilled != null) {
|
||||||
|
return _moreVertFilled!!
|
||||||
|
}
|
||||||
|
_moreVertFilled = materialIcon(name = "Filled.MoreVert") {
|
||||||
|
materialPath {
|
||||||
|
moveTo(12.0f, 8.0f)
|
||||||
|
curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 10.0f)
|
||||||
|
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
close()
|
||||||
|
moveTo(12.0f, 16.0f)
|
||||||
|
curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(0.9f, 2.0f, 2.0f, 2.0f)
|
||||||
|
reflectiveCurveToRelative(2.0f, -0.9f, 2.0f, -2.0f)
|
||||||
|
reflectiveCurveToRelative(-0.9f, -2.0f, -2.0f, -2.0f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _moreVertFilled!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _moreVertFilled: ImageVector? = null
|
||||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.model.AppPreferences
|
import chat.simplex.app.model.AppPreferences
|
||||||
|
import chat.simplex.app.model.SharedPreference
|
||||||
import chat.simplex.app.views.usersettings.Cryptor
|
import chat.simplex.app.views.usersettings.Cryptor
|
||||||
import kotlinx.serialization.*
|
import kotlinx.serialization.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -16,30 +17,36 @@ object DatabaseUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
private const val DATABASE_PASSWORD_ALIAS: String = "databasePassword"
|
||||||
|
private const val APP_PASSWORD_ALIAS: String = "appPassword"
|
||||||
|
|
||||||
|
val ksDatabasePassword = KeyStoreItem(DATABASE_PASSWORD_ALIAS, appPreferences.encryptedDBPassphrase, appPreferences.initializationVectorDBPassphrase)
|
||||||
|
val ksAppPassword = KeyStoreItem(APP_PASSWORD_ALIAS, appPreferences.encryptedAppPassphrase, appPreferences.initializationVectorAppPassphrase)
|
||||||
|
|
||||||
|
class KeyStoreItem(private val alias: String, val passphrase: SharedPreference<String?>, val initVector: SharedPreference<String?>) {
|
||||||
|
fun get(): String? {
|
||||||
|
return cryptor.decryptData(
|
||||||
|
passphrase.get()?.toByteArrayFromBase64() ?: return null,
|
||||||
|
initVector.get()?.toByteArrayFromBase64() ?: return null,
|
||||||
|
alias,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(key: String) {
|
||||||
|
val data = cryptor.encryptText(key, alias)
|
||||||
|
passphrase.set(data.first.toBase64String())
|
||||||
|
initVector.set(data.second.toBase64String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove() {
|
||||||
|
cryptor.deleteKey(alias)
|
||||||
|
passphrase.set(null)
|
||||||
|
initVector.set(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun hasDatabase(rootDir: String): Boolean =
|
private fun hasDatabase(rootDir: String): Boolean =
|
||||||
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
File(rootDir + File.separator + "files_chat.db").exists() && File(rootDir + File.separator + "files_agent.db").exists()
|
||||||
|
|
||||||
fun getDatabaseKey(): String? {
|
|
||||||
return cryptor.decryptData(
|
|
||||||
appPreferences.encryptedDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
|
||||||
appPreferences.initializationVectorDBPassphrase.get()?.toByteArrayFromBase64() ?: return null,
|
|
||||||
DATABASE_PASSWORD_ALIAS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setDatabaseKey(key: String) {
|
|
||||||
val data = cryptor.encryptText(key, DATABASE_PASSWORD_ALIAS)
|
|
||||||
appPreferences.encryptedDBPassphrase.set(data.first.toBase64String())
|
|
||||||
appPreferences.initializationVectorDBPassphrase.set(data.second.toBase64String())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeDatabaseKey() {
|
|
||||||
cryptor.deleteKey(DATABASE_PASSWORD_ALIAS)
|
|
||||||
appPreferences.encryptedDBPassphrase.set(null)
|
|
||||||
appPreferences.initializationVectorDBPassphrase.set(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun useDatabaseKey(): String {
|
fun useDatabaseKey(): String {
|
||||||
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
Log.d(TAG, "useDatabaseKey ${appPreferences.storeDBPassphrase.get()}")
|
||||||
var dbKey = ""
|
var dbKey = ""
|
||||||
@@ -47,10 +54,10 @@ object DatabaseUtils {
|
|||||||
if (useKeychain) {
|
if (useKeychain) {
|
||||||
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
if (!hasDatabase(SimplexApp.context.dataDir.absolutePath)) {
|
||||||
dbKey = randomDatabasePassword()
|
dbKey = randomDatabasePassword()
|
||||||
setDatabaseKey(dbKey)
|
ksDatabasePassword.set(dbKey)
|
||||||
appPreferences.initialRandomDBPassphrase.set(true)
|
appPreferences.initialRandomDBPassphrase.set(true)
|
||||||
} else {
|
} else {
|
||||||
dbKey = getDatabaseKey() ?: ""
|
dbKey = ksDatabasePassword.get() ?: ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dbKey
|
return dbKey
|
||||||
@@ -66,8 +73,39 @@ object DatabaseUtils {
|
|||||||
@Serializable
|
@Serializable
|
||||||
sealed class DBMigrationResult {
|
sealed class DBMigrationResult {
|
||||||
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
@Serializable @SerialName("ok") object OK: DBMigrationResult()
|
||||||
|
@Serializable @SerialName("invalidConfirmation") object InvalidConfirmation: DBMigrationResult()
|
||||||
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
@Serializable @SerialName("errorNotADatabase") class ErrorNotADatabase(val dbFile: String): DBMigrationResult()
|
||||||
@Serializable @SerialName("error") class Error(val dbFile: String, val migrationError: String): DBMigrationResult()
|
@Serializable @SerialName("errorMigration") class ErrorMigration(val dbFile: String, val migrationError: MigrationError): DBMigrationResult()
|
||||||
|
@Serializable @SerialName("errorSQL") class ErrorSQL(val dbFile: String, val migrationSQLError: String): DBMigrationResult()
|
||||||
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
@Serializable @SerialName("errorKeychain") object ErrorKeychain: DBMigrationResult()
|
||||||
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
@Serializable @SerialName("unknown") class Unknown(val json: String): DBMigrationResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
enum class MigrationConfirmation(val value: String) {
|
||||||
|
YesUp("yesUp"),
|
||||||
|
YesUpDown ("yesUpDown"),
|
||||||
|
Error("error")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun defaultMigrationConfirmation(appPrefs: AppPreferences): MigrationConfirmation =
|
||||||
|
if (appPrefs.confirmDBUpgrades.get()) MigrationConfirmation.Error else MigrationConfirmation.YesUp
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class MigrationError {
|
||||||
|
@Serializable @SerialName("upgrade") class Upgrade(val upMigrations: List<UpMigration>): MigrationError()
|
||||||
|
@Serializable @SerialName("downgrade") class Downgrade(val downMigrations: List<String>): MigrationError()
|
||||||
|
@Serializable @SerialName("migrationError") class Error(val mtrError: MTRError): MigrationError()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class UpMigration(
|
||||||
|
val upName: String,
|
||||||
|
// val withDown: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class MTRError {
|
||||||
|
@Serializable @SerialName("noDown") class NoDown(val dbMigrations: List<String>): MTRError()
|
||||||
|
@Serializable @SerialName("different") class Different(val appMigration: String, val dbMigration: String): MTRError()
|
||||||
|
}
|
||||||
|
|||||||
+115
-2
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||||
import androidx.compose.foundation.text.*
|
import androidx.compose.foundation.text.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
@@ -14,13 +15,19 @@ import androidx.compose.ui.focus.*
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.text.TextRange
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.*
|
import androidx.compose.ui.text.input.*
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import chat.simplex.app.R
|
||||||
|
import chat.simplex.app.views.database.PassphraseStrength
|
||||||
|
import chat.simplex.app.views.database.validKey
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalComposeUiApi::class)
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -110,3 +117,109 @@ fun DefaultBasicTextField(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun DefaultConfigurableTextField(
|
||||||
|
state: MutableState<TextFieldValue>,
|
||||||
|
placeholder: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
showPasswordStrength: Boolean = false,
|
||||||
|
isValid: (String) -> Boolean,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions(),
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
dependsOn: State<Any?>? = null,
|
||||||
|
) {
|
||||||
|
var valid by remember { mutableStateOf(validKey(state.value.text)) }
|
||||||
|
var showKey by remember { mutableStateOf(false) }
|
||||||
|
val icon = if (valid) {
|
||||||
|
if (showKey) painterResource(R.drawable.ic_visibility_off_filled) else painterResource(R.drawable.ic_visibility_filled)
|
||||||
|
} else painterResource(R.drawable.ic_error)
|
||||||
|
val iconColor = if (valid) {
|
||||||
|
if (showPasswordStrength && state.value.text.isNotEmpty()) PassphraseStrength.check(state.value.text).color else MaterialTheme.colors.secondary
|
||||||
|
} else Color.Red
|
||||||
|
val keyboard = LocalSoftwareKeyboardController.current
|
||||||
|
val keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = if (keyboardActions.onNext != null) ImeAction.Next else ImeAction.Done,
|
||||||
|
autoCorrect = keyboardType != KeyboardType.Password,
|
||||||
|
keyboardType = keyboardType
|
||||||
|
)
|
||||||
|
val enabled = true
|
||||||
|
val colors = TextFieldDefaults.textFieldColors(
|
||||||
|
backgroundColor = Color.Unspecified,
|
||||||
|
textColor = MaterialTheme.colors.onBackground,
|
||||||
|
focusedIndicatorColor = Color.Unspecified,
|
||||||
|
unfocusedIndicatorColor = Color.Unspecified,
|
||||||
|
)
|
||||||
|
val color = MaterialTheme.colors.onBackground
|
||||||
|
val shape = MaterialTheme.shapes.small.copy(bottomEnd = ZeroCornerSize, bottomStart = ZeroCornerSize)
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
BasicTextField(
|
||||||
|
value = state.value,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(colors.backgroundColor(enabled).value, shape)
|
||||||
|
.indicatorLine(enabled, false, interactionSource, colors)
|
||||||
|
.defaultMinSize(
|
||||||
|
minWidth = TextFieldDefaults.MinWidth,
|
||||||
|
minHeight = TextFieldDefaults.MinHeight
|
||||||
|
),
|
||||||
|
onValueChange = {
|
||||||
|
state.value = it
|
||||||
|
},
|
||||||
|
cursorBrush = SolidColor(colors.cursorColor(false).value),
|
||||||
|
visualTransformation = if (showKey || keyboardType != KeyboardType.Password)
|
||||||
|
VisualTransformation.None
|
||||||
|
else
|
||||||
|
VisualTransformation { TransformedText(AnnotatedString(it.text.map { "*" }.joinToString(separator = "")), OffsetMapping.Identity) },
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
keyboard?.hide()
|
||||||
|
keyboardActions.onDone?.invoke(this)
|
||||||
|
}),
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = TextStyle.Default.copy(
|
||||||
|
color = color,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
decorationBox = @Composable { innerTextField ->
|
||||||
|
TextFieldDefaults.TextFieldDecorationBox(
|
||||||
|
value = state.value.text,
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
placeholder = { Text(placeholder, color = MaterialTheme.colors.secondary) },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = enabled,
|
||||||
|
isError = !valid,
|
||||||
|
trailingIcon = {
|
||||||
|
if (keyboardType == KeyboardType.Password || !valid) {
|
||||||
|
IconButton({ showKey = !showKey }) {
|
||||||
|
Icon(icon, null, tint = iconColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
contentPadding = TextFieldDefaults.textFieldWithLabelPadding(start = 0.dp, end = 0.dp),
|
||||||
|
visualTransformation = VisualTransformation.None,
|
||||||
|
colors = colors
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
launch {
|
||||||
|
snapshotFlow { state.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(it.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
snapshotFlow { dependsOn?.value }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
valid = isValid(state.value.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CornerSize
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import chat.simplex.app.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultDropdownMenu(
|
||||||
|
showMenu: MutableState<Boolean>,
|
||||||
|
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||||
|
dropdownMenuItems: (@Composable () -> Unit)?
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu.value,
|
||||||
|
onDismissRequest = { showMenu.value = false },
|
||||||
|
Modifier
|
||||||
|
.widthIn(min = 250.dp)
|
||||||
|
.background(MaterialTheme.colors.surface)
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
offset = offset,
|
||||||
|
) {
|
||||||
|
dropdownMenuItems?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
|
||||||
|
expanded: MutableState<Boolean>,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dropdownMenuItems: (@Composable () -> Unit)?
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
|
||||||
|
) {
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 200.dp)
|
||||||
|
.background(MaterialTheme.colors.surface)
|
||||||
|
.then(modifier),
|
||||||
|
expanded = expanded.value,
|
||||||
|
onDismissRequest = {
|
||||||
|
expanded.value = false
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
dropdownMenuItems?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.material.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.*
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DefaultSwitch(
|
||||||
|
checked: Boolean,
|
||||||
|
onCheckedChange: ((Boolean) -> Unit)?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
colors: SwitchColors = SwitchDefaults.colors(
|
||||||
|
checkedThumbColor = MaterialTheme.colors.primary,
|
||||||
|
uncheckedThumbColor = MaterialTheme.colors.secondary,
|
||||||
|
checkedTrackAlpha = 0.0f,
|
||||||
|
uncheckedTrackAlpha = 0.0f,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val color = if (checked) MaterialTheme.colors.primary.copy(alpha = 0.3f) else MaterialTheme.colors.secondary.copy(alpha = 0.3f)
|
||||||
|
val size = with(LocalDensity.current) { Size(46.dp.toPx(), 28.dp.toPx()) }
|
||||||
|
val offset = with(LocalDensity.current) { Offset(1.dp.toPx(), 10.dp.toPx()) }
|
||||||
|
val radius = with(LocalDensity.current) { 28.dp.toPx() }
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier.drawBehind { drawRoundRect(color, size = size, topLeft = offset, cornerRadius = CornerRadius(radius, radius)) },
|
||||||
|
colors = colors,
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,12 +4,11 @@ import chat.simplex.app.R
|
|||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.*
|
import androidx.compose.ui.graphics.*
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import chat.simplex.app.ui.theme.*
|
import chat.simplex.app.ui.theme.*
|
||||||
@@ -34,7 +33,7 @@ fun DefaultTopAppBar(
|
|||||||
if (!showSearch) {
|
if (!showSearch) {
|
||||||
title?.invoke()
|
title?.invoke()
|
||||||
} else {
|
} else {
|
||||||
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), onSearchValueChanged)
|
SearchTextField(Modifier.fillMaxWidth(), stringResource(android.R.string.search_go), alwaysVisible = false, onSearchValueChanged)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
backgroundColor = if (isInDarkTheme()) ToolbarDark else ToolbarLight,
|
||||||
@@ -45,10 +44,19 @@ fun DefaultTopAppBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?) {
|
||||||
|
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.ic_arrow_back_ios_new), stringResource(R.string.back), tint = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShareButton(onButtonClicked: () -> Unit) {
|
||||||
IconButton(onButtonClicked) {
|
IconButton(onButtonClicked) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.ArrowBackIos, stringResource(R.string.back), tint = MaterialTheme.colors.primary
|
painterResource(R.drawable.ic_share), stringResource(R.string.share_verb), tint = MaterialTheme.colors.primary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +65,7 @@ fun NavigationButtonBack(onButtonClicked: () -> Unit) {
|
|||||||
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
|
||||||
IconButton(onClick = onButtonClicked) {
|
IconButton(onClick = onButtonClicked) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Outlined.Menu,
|
painterResource(R.drawable.ic_menu),
|
||||||
stringResource(R.string.icon_descr_settings),
|
stringResource(R.string.icon_descr_settings),
|
||||||
tint = MaterialTheme.colors.primary,
|
tint = MaterialTheme.colors.primary,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
|
@file:UseSerializers(UriSerializer::class)
|
||||||
package chat.simplex.app.views.helpers
|
package chat.simplex.app.views.helpers
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.serialization.*
|
||||||
|
import kotlinx.serialization.descriptors.*
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
sealed class SharedContent {
|
sealed class SharedContent {
|
||||||
data class Text(val text: String): SharedContent()
|
data class Text(val text: String): SharedContent()
|
||||||
data class Images(val text: String, val uris: List<Uri>): SharedContent()
|
data class Media(val text: String, val uris: List<Uri>): SharedContent()
|
||||||
data class File(val text: String, val uri: Uri): SharedContent()
|
data class File(val text: String, val uri: Uri): SharedContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class NewChatSheetState {
|
enum class AnimatedViewState {
|
||||||
VISIBLE, HIDING, GONE;
|
VISIBLE, HIDING, GONE;
|
||||||
fun isVisible(): Boolean {
|
fun isVisible(): Boolean {
|
||||||
return this == VISIBLE
|
return this == VISIBLE
|
||||||
@@ -23,7 +27,7 @@ enum class NewChatSheetState {
|
|||||||
return this == GONE
|
return this == GONE
|
||||||
}
|
}
|
||||||
companion object {
|
companion object {
|
||||||
fun saver(): Saver<MutableStateFlow<NewChatSheetState>, *> = Saver(
|
fun saver(): Saver<MutableStateFlow<AnimatedViewState>, *> = Saver(
|
||||||
save = { it.value.toString() },
|
save = { it.value.toString() },
|
||||||
restore = {
|
restore = {
|
||||||
MutableStateFlow(valueOf(it))
|
MutableStateFlow(valueOf(it))
|
||||||
@@ -32,7 +36,17 @@ enum class NewChatSheetState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class UploadContent {
|
|
||||||
data class SimpleImage(val uri: Uri): UploadContent()
|
@Serializer(forClass = Uri::class)
|
||||||
data class AnimatedImage(val uri: Uri): UploadContent()
|
object UriSerializer : KSerializer<Uri> {
|
||||||
|
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uri", PrimitiveKind.STRING)
|
||||||
|
override fun serialize(encoder: Encoder, value: Uri) = encoder.encodeString(value.toString())
|
||||||
|
override fun deserialize(decoder: Decoder): Uri = Uri.parse(decoder.decodeString())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
sealed class UploadContent {
|
||||||
|
@Serializable data class SimpleImage(val uri: Uri): UploadContent()
|
||||||
|
@Serializable data class AnimatedImage(val uri: Uri): UploadContent()
|
||||||
|
@Serializable data class Video(val uri: Uri, val duration: Int): UploadContent()
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-36
@@ -2,18 +2,19 @@ package chat.simplex.app.views.helpers
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.*
|
import androidx.compose.material.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.material.icons.outlined.ExpandLess
|
|
||||||
import androidx.compose.material.icons.outlined.ExpandMore
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.ui.theme.HighOrLowlight
|
import chat.simplex.app.ui.theme.*
|
||||||
|
import chat.simplex.app.views.usersettings.SettingsActionItemWithContent
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun <T> ExposedDropDownSettingRow(
|
fun <T> ExposedDropDownSettingRow(
|
||||||
@@ -21,33 +22,17 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
values: List<Pair<T, String>>,
|
values: List<Pair<T, String>>,
|
||||||
selection: State<T>,
|
selection: State<T>,
|
||||||
label: String? = null,
|
label: String? = null,
|
||||||
icon: ImageVector? = null,
|
icon: Painter? = null,
|
||||||
iconTint: Color = HighOrLowlight,
|
iconTint: Color = MaterialTheme.colors.secondary,
|
||||||
enabled: State<Boolean> = mutableStateOf(true),
|
enabled: State<Boolean> = mutableStateOf(true),
|
||||||
onSelected: (T) -> Unit
|
onSelected: (T) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
|
||||||
Modifier.fillMaxWidth(),
|
val expanded = remember { mutableStateOf(false) }
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
if (icon != null) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
"",
|
|
||||||
Modifier.padding(end = 8.dp),
|
|
||||||
tint = iconTint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(title, color = if (enabled.value) Color.Unspecified else HighOrLowlight)
|
|
||||||
|
|
||||||
Spacer(Modifier.fillMaxWidth().weight(1f))
|
|
||||||
|
|
||||||
ExposedDropdownMenuBox(
|
ExposedDropdownMenuBox(
|
||||||
expanded = expanded,
|
expanded = expanded.value,
|
||||||
onExpandedChange = {
|
onExpandedChange = {
|
||||||
expanded = !expanded && enabled.value
|
expanded.value = !expanded.value && enabled.value
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -55,37 +40,38 @@ fun <T> ExposedDropDownSettingRow(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.End
|
horizontalArrangement = Arrangement.End
|
||||||
) {
|
) {
|
||||||
|
val maxWidth = with(LocalDensity.current) { 180.sp.toDp() }
|
||||||
Text(
|
Text(
|
||||||
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
values.first { it.first == selection.value }.second + (if (label != null) " $label" else ""),
|
||||||
|
Modifier.widthIn(max = maxWidth),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = HighOrLowlight
|
color = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
Spacer(Modifier.size(12.dp))
|
Spacer(Modifier.size(12.dp))
|
||||||
Icon(
|
Icon(
|
||||||
if (!expanded) Icons.Outlined.ExpandMore else Icons.Outlined.ExpandLess,
|
if (!expanded.value) painterResource(R.drawable.ic_expand_more) else painterResource(R.drawable.ic_expand_less),
|
||||||
generalGetString(R.string.icon_descr_more_button),
|
generalGetString(R.string.icon_descr_more_button),
|
||||||
tint = HighOrLowlight
|
tint = MaterialTheme.colors.secondary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ExposedDropdownMenu(
|
DefaultExposedDropdownMenu(
|
||||||
modifier = Modifier.widthIn(min = 200.dp),
|
modifier = Modifier.widthIn(min = 200.dp),
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = {
|
|
||||||
expanded = false
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
values.forEach { selectionOption ->
|
values.forEach { selectionOption ->
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
onClick = {
|
onClick = {
|
||||||
onSelected(selectionOption.first)
|
onSelected(selectionOption.first)
|
||||||
expanded = false
|
expanded.value = false
|
||||||
}
|
},
|
||||||
|
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
selectionOption.second + (if (label != null) " $label" else ""),
|
selectionOption.second + (if (label != null) " $label" else ""),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = if (isInDarkTheme()) MenuTextColorDark else Color.Black,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,10 +80,16 @@ suspend fun PointerInputScope.detectGesture(
|
|||||||
pressScope.release()
|
pressScope.release()
|
||||||
}
|
}
|
||||||
} catch (_: PointerEventTimeoutCancellationException) {
|
} catch (_: PointerEventTimeoutCancellationException) {
|
||||||
onLongPress?.invoke(down.position)
|
if (onLongPress != null) {
|
||||||
if (shouldConsume)
|
onLongPress(down.position)
|
||||||
consumeUntilUp()
|
if (shouldConsume)
|
||||||
pressScope.release()
|
consumeUntilUp()
|
||||||
|
pressScope.cancel()
|
||||||
|
} else {
|
||||||
|
if (shouldConsume)
|
||||||
|
consumeUntilUp()
|
||||||
|
pressScope.release()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,6 +219,24 @@ fun interactionSourceWithDetection(onClick: () -> Unit, onLongClick: () -> Unit)
|
|||||||
return interactionSource
|
return interactionSource
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun interactionSourceWithTapDetection(onPress: () -> Unit, onClick: () -> Unit, onCancel: () -> Unit, onRelease: ()-> Unit): MutableInteractionSource {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
var firstTapTime = 0L
|
||||||
|
interactionSource.interactions.collect { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
firstTapTime = System.currentTimeMillis(); onPress()
|
||||||
|
}
|
||||||
|
is PressInteraction.Release -> if (firstTapTime + 1000L < System.currentTimeMillis()) onRelease() else onClick()
|
||||||
|
is PressInteraction.Cancel -> onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return interactionSource
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun PointerInputScope.detectTransformGestures(
|
suspend fun PointerInputScope.detectTransformGestures(
|
||||||
allowIntercept: () -> Boolean,
|
allowIntercept: () -> Boolean,
|
||||||
panZoomLock: Boolean = false,
|
panZoomLock: Boolean = false,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package chat.simplex.app.views.helpers
|
|||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.*
|
import android.content.*
|
||||||
|
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.*
|
import android.graphics.*
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -16,15 +17,13 @@ import androidx.activity.result.contract.ActivityResultContract
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.annotation.CallSuper
|
import androidx.annotation.CallSuper
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.Collections
|
|
||||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.saveable.Saver
|
import androidx.compose.runtime.saveable.Saver
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
@@ -32,11 +31,9 @@ import androidx.core.content.FileProvider
|
|||||||
import chat.simplex.app.*
|
import chat.simplex.app.*
|
||||||
import chat.simplex.app.R
|
import chat.simplex.app.R
|
||||||
import chat.simplex.app.model.json
|
import chat.simplex.app.model.json
|
||||||
import chat.simplex.app.views.chat.ComposeState
|
|
||||||
import chat.simplex.app.views.chat.PickFromGallery
|
import chat.simplex.app.views.chat.PickFromGallery
|
||||||
import chat.simplex.app.views.newchat.ActionButton
|
import chat.simplex.app.views.newchat.ActionButton
|
||||||
import kotlinx.serialization.builtins.*
|
import kotlinx.serialization.builtins.*
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -114,7 +111,7 @@ fun base64ToBitmap(base64ImageString: String): Bitmap {
|
|||||||
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
class CustomTakePicturePreview(var uri: Uri?, var tmpFile: File?): ActivityResultContract<Void?, Uri?>() {
|
||||||
@CallSuper
|
@CallSuper
|
||||||
override fun createIntent(context: Context, input: Void?): Intent {
|
override fun createIntent(context: Context, input: Void?): Intent {
|
||||||
tmpFile = File.createTempFile("image", ".bmp", context.filesDir)
|
tmpFile = File.createTempFile("image", ".bmp", File(getAppFilesDirectory(SimplexApp.context)))
|
||||||
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
// Since the class should return Uri, the file should be deleted somewhere else. And in order to be sure, delegate this to system
|
||||||
tmpFile?.deleteOnExit()
|
tmpFile?.deleteOnExit()
|
||||||
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", tmpFile!!)
|
||||||
@@ -175,7 +172,37 @@ fun rememberGetContentLauncher(cb: (Uri?) -> Unit): ManagedActivityResultLaunche
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
fun rememberGetMultipleContentsLauncher(cb: (List<Uri>) -> Unit): ManagedActivityResultLauncher<String, List<Uri>> =
|
||||||
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetMultipleContents(), cb)
|
rememberLauncherForActivityResult(contract = GetMultipleContentsAndMimeTypes(), cb)
|
||||||
|
|
||||||
|
class GetMultipleContentsAndMimeTypes: ActivityResultContracts.GetMultipleContents() {
|
||||||
|
override fun createIntent(context: Context, input: String): Intent {
|
||||||
|
val mimeTypes = input.split(";")
|
||||||
|
return super.createIntent(context, mimeTypes[0]).apply {
|
||||||
|
if (mimeTypes.isNotEmpty()) {
|
||||||
|
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ManagedActivityResultLauncher<Void?, Uri?>.launchWithFallback() {
|
||||||
|
try {
|
||||||
|
launch(null)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// No Activity found to handle Intent android.media.action.IMAGE_CAPTURE
|
||||||
|
// Means, no system camera app (Android 11+ requirement)
|
||||||
|
// https://developer.android.com/about/versions/11/behavior-changes-11#media-capture
|
||||||
|
Log.e(TAG, "Camera launcher: " + e.stackTraceToString())
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to open any camera just to capture an image, will not be returned like with previous intent
|
||||||
|
SimplexApp.context.startActivity(Intent(MediaStore.INTENT_ACTION_STILL_IMAGE_CAMERA).also { it.addFlags(FLAG_ACTIVITY_NEW_TASK) })
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
// No camera apps available at all
|
||||||
|
Log.e(TAG, "Camera launcher2: " + e.stackTraceToString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun GetImageBottomSheet(
|
fun GetImageBottomSheet(
|
||||||
@@ -186,25 +213,19 @@ fun GetImageBottomSheet(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val processPickedImage = { uri: Uri? ->
|
val processPickedImage = { uri: Uri? ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val source = ImageDecoder.createSource(context.contentResolver, uri)
|
val bitmap = getBitmapFromUri(uri)
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
if (bitmap != null) {
|
||||||
imageBitmap.value = uri
|
imageBitmap.value = uri
|
||||||
onImageChange(bitmap)
|
onImageChange(bitmap)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery()) { processPickedImage(it) }
|
|
||||||
val galleryLauncherFallback = rememberGetContentLauncher { processPickedImage(it) }
|
|
||||||
val cameraLauncher = rememberCameraLauncher { uri: Uri? ->
|
|
||||||
if (uri != null) {
|
|
||||||
imageBitmap.value = uri
|
|
||||||
val source = ImageDecoder.createSource(SimplexApp.context.contentResolver, uri)
|
|
||||||
val bitmap = ImageDecoder.decodeBitmap(source)
|
|
||||||
onImageChange(bitmap)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val galleryLauncher = rememberLauncherForActivityResult(contract = PickFromGallery(), processPickedImage)
|
||||||
|
val galleryLauncherFallback = rememberGetContentLauncher(processPickedImage)
|
||||||
|
val cameraLauncher = rememberCameraLauncher(processPickedImage)
|
||||||
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean ->
|
||||||
if (isGranted) {
|
if (isGranted) {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
hideBottomSheet()
|
hideBottomSheet()
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, generalGetString(R.string.toast_permission_denied), Toast.LENGTH_SHORT).show()
|
||||||
@@ -225,10 +246,10 @@ fun GetImageBottomSheet(
|
|||||||
.padding(horizontal = 8.dp, vertical = 30.dp),
|
.padding(horizontal = 8.dp, vertical = 30.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
ActionButton(null, stringResource(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) {
|
ActionButton(null, stringResource(R.string.use_camera_button), icon = painterResource(R.drawable.ic_photo_camera)) {
|
||||||
when (PackageManager.PERMISSION_GRANTED) {
|
when (PackageManager.PERMISSION_GRANTED) {
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> {
|
||||||
cameraLauncher.launch(null)
|
cameraLauncher.launchWithFallback()
|
||||||
hideBottomSheet()
|
hideBottomSheet()
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
@@ -236,7 +257,7 @@ fun GetImageBottomSheet(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Collections) {
|
ActionButton(null, stringResource(R.string.from_gallery_button), icon = painterResource(R.drawable.ic_image)) {
|
||||||
try {
|
try {
|
||||||
galleryLauncher.launch(0)
|
galleryLauncher.launch(0)
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user