mirror of
https://gitlab.futo.org/videostreaming/grayjay.git
synced 2026-05-16 04:52:39 +02:00
Compare commits
1166 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a792dea4c5 | |||
| a7fc549afb | |||
| b345ba5ca3 | |||
| c65cee86b1 | |||
| cf3fc61f6a | |||
| d03019f0b7 | |||
| f1ce0078fd | |||
| 32de3649ef | |||
| 1a301236da | |||
| c695379885 | |||
| 73466892f7 | |||
| bb8a9d4dd7 | |||
| 43ed2b16ab | |||
| 64938dba6c | |||
| 8b7d51cd70 | |||
| ace7ca1551 | |||
| 22b5adc4b8 | |||
| 0f7fb9059b | |||
| 05afa12274 | |||
| b4a280cee8 | |||
| ac5d7eab2a | |||
| b624d45ab6 | |||
| 5340088ada | |||
| fcab0f5ee5 | |||
| 80c9b27d48 | |||
| f54216d52f | |||
| fea69d265a | |||
| 030086e769 | |||
| 81516c31fb | |||
| 3d13a21700 | |||
| cd90497a59 | |||
| c14d2580ee | |||
| 795259564d | |||
| 81d0b08306 | |||
| 9a97a901fb | |||
| d9b23eff62 | |||
| 8591deaf86 | |||
| 22c5581d00 | |||
| 6e815dc868 | |||
| 1ac409561c | |||
| 897ba8a560 | |||
| 8982ea2289 | |||
| f693f1e6b3 | |||
| e118bc09b9 | |||
| 5ba77b60c8 | |||
| 19b63ba372 | |||
| 5fc39d3bb3 | |||
| 1d046538f8 | |||
| 9f10b86861 | |||
| d1336c711a | |||
| 837ee76bdc | |||
| 2a2ed08a3c | |||
| 8a0e49232e | |||
| 624ef3c6e9 | |||
| 3d5b9a94fb | |||
| a8decdb0d9 | |||
| 2609929780 | |||
| 2bcfbf89d3 | |||
| fa1954ceef | |||
| 13aa49726a | |||
| 20bab7d056 | |||
| cbf7ca0181 | |||
| b7477080d2 | |||
| ac5bc27581 | |||
| 748551af2a | |||
| 9ce41bc8d0 | |||
| 8cf542e201 | |||
| 88950843b3 | |||
| 4a08058322 | |||
| 7b76ba1539 | |||
| 6492278e7d | |||
| 9de9440160 | |||
| 372af6cf47 | |||
| 29d08c8554 | |||
| cfeceabe5b | |||
| a51f609a92 | |||
| 15a655f196 | |||
| c6525f1caa | |||
| e147fdd77e | |||
| 6a8ac0bfaa | |||
| 772bff6bc0 | |||
| b6b04054b9 | |||
| 1ea794459c | |||
| c27f5e4096 | |||
| 8469f17b4c | |||
| 067abc415b | |||
| d692533f20 | |||
| 31a6ea0f39 | |||
| 5ba2f2be75 | |||
| 8e4ad54de1 | |||
| 6139696714 | |||
| 8536861e09 | |||
| 71262da3c2 | |||
| 60cd5976cc | |||
| 3ca6a1fd70 | |||
| 0d8c8de450 | |||
| 8ba2fe9972 | |||
| 7a7ef533cc | |||
| 5385549a43 | |||
| 04deffc66e | |||
| 852f563c9a | |||
| c84cea9ea1 | |||
| 5c162083d5 | |||
| 3230e7c0b4 | |||
| 8437825dd1 | |||
| 0fbe0bb438 | |||
| 34d2e62314 | |||
| 1075ded170 | |||
| 80bb15f3fb | |||
| 27a86a67f0 | |||
| 284b2a24f8 | |||
| 854d1506a6 | |||
| 811fd4e73e | |||
| 335988aa67 | |||
| 29a54fbed4 | |||
| 3a11d0d9d1 | |||
| bda534e485 | |||
| 09fd4c0881 | |||
| 1667866a35 | |||
| 035125d0f8 | |||
| 1bb0cdc405 | |||
| 86019c80a1 | |||
| 8c640d3def | |||
| 7ed1e8a28b | |||
| 3dcfe8c340 | |||
| 042ced81ef | |||
| b37f48380b | |||
| 0a02169782 | |||
| f12e4390f3 | |||
| 82ab45d04e | |||
| 7f77c39296 | |||
| 99eee4f6ee | |||
| 68886502d1 | |||
| 26461c21c4 | |||
| 300466f722 | |||
| 961710cc8b | |||
| eba995f87d | |||
| a67244e79a | |||
| 70502a7651 | |||
| 36b4f5b41d | |||
| def39ba397 | |||
| 49d59f4466 | |||
| 1c9becc2ba | |||
| 1cde591061 | |||
| 8ac18f053c | |||
| 56bdae9ff1 | |||
| 74ddfe9f0e | |||
| acb9500e2a | |||
| 45f621763a | |||
| 0abc65a9bd | |||
| 6d6309973e | |||
| 92ec085d25 | |||
| 767a8befaa | |||
| 09763320dd | |||
| 27fb2997f9 | |||
| 0f46bc5888 | |||
| dccf4fcf3c | |||
| da7fef1ecd | |||
| 58a89a00ef | |||
| f2efc603ba | |||
| efe074d272 | |||
| 8a9efd3a0f | |||
| 251302b9c3 | |||
| 5cdac1405e | |||
| 894e400819 | |||
| 565ea7cb8b | |||
| 9fa3e22d2e | |||
| 5548783337 | |||
| 0dca8798cb | |||
| d902306fe4 | |||
| baa2a4fcf3 | |||
| 8be7ad9f68 | |||
| 992cbcb3a0 | |||
| e0857aea9b | |||
| 50cd0723c9 | |||
| 4c4b322682 | |||
| 7cff8568c0 | |||
| 801c646a09 | |||
| df4ec87613 | |||
| b08a79b7cb | |||
| 396e9f9f43 | |||
| 0e5a87a911 | |||
| 64d72f6d10 | |||
| 5b40da109b | |||
| 949294952f | |||
| 40a058e369 | |||
| a070d78dd9 | |||
| 105ac538bb | |||
| ce2029774e | |||
| 50c63d7e8d | |||
| d3534080d7 | |||
| b5025193a5 | |||
| 3f85b7ed78 | |||
| 98d008ef6c | |||
| 20eb53fc38 | |||
| 1ea7b307fa | |||
| f18571e0b2 | |||
| 70872d429a | |||
| cbf3db6e30 | |||
| 0be0dcfadc | |||
| abd226c33d | |||
| 89dbdc99a0 | |||
| f89ed18a49 | |||
| 77ac2b537c | |||
| 8ab03b6b66 | |||
| dad70e57c6 | |||
| eb9c6c8330 | |||
| 68da797f4d | |||
| 25948dd296 | |||
| 10d39d6ed1 | |||
| 85e8e674dd | |||
| 0d70392bf0 | |||
| f89b074d28 | |||
| ee2af411aa | |||
| 9ffbe6dd03 | |||
| 0ae6ac2fac | |||
| fd835cc54e | |||
| 07f3140038 | |||
| 3e753d70de | |||
| d578c47975 | |||
| b7a61425ca | |||
| 727f977672 | |||
| fc9d5eeb27 | |||
| f17e147b4e | |||
| 1c569b465b | |||
| 6289c85bd5 | |||
| 098599853b | |||
| 68d11f6d58 | |||
| 74f6b9aa62 | |||
| fc2aba0120 | |||
| e4f51bb130 | |||
| 9e9d26c752 | |||
| 5c5dd3af44 | |||
| 4433364cd8 | |||
| 2c957d7188 | |||
| f229f4ed1f | |||
| e8d1f73e29 | |||
| dd2cf18cb2 | |||
| 5355602577 | |||
| 8cc82e4d16 | |||
| d6468ba283 | |||
| 4b5ed38175 | |||
| 75eb7359de | |||
| fd519d48cf | |||
| 6f1866ac27 | |||
| 0dc0f07785 | |||
| bae8cb7bc4 | |||
| d5a696289b | |||
| 75ef7085eb | |||
| 347ef855b3 | |||
| aac19aef86 | |||
| 33efc5c21d | |||
| fc7001c295 | |||
| 9b68394f70 | |||
| e2ef8c2593 | |||
| 551bfe44ac | |||
| 6fbfa98ad3 | |||
| 9b97e05e3b | |||
| 62a2f42d68 | |||
| da44e86163 | |||
| 682b86330e | |||
| c9ba8a09e2 | |||
| 7d19c2357c | |||
| 64030a038c | |||
| 87d93c2ed8 | |||
| 9d9ad52535 | |||
| b10cf6a323 | |||
| 4407e82d8a | |||
| 3113dc53a6 | |||
| bd25276720 | |||
| 29d3a9986e | |||
| bce93b8e0f | |||
| 9a950958f9 | |||
| b6676e7763 | |||
| 35fe093e5c | |||
| 7cad4fbe07 | |||
| 240772790d | |||
| d659ecc518 | |||
| 7d8bb20b71 | |||
| 1cf5f776d5 | |||
| 137ba85538 | |||
| 642d218c54 | |||
| 26b5470200 | |||
| 547fe7bc13 | |||
| 678305e366 | |||
| 9f07673d85 | |||
| 19429263a9 | |||
| 986652adab | |||
| 4d93a58d5d | |||
| 817c90f3af | |||
| 77348b3787 | |||
| 31e26d03c6 | |||
| 1ef566ab16 | |||
| 7597f5136c | |||
| 9a2a70622f | |||
| 4fc33411fd | |||
| a9bb900994 | |||
| 8c1a18d8b4 | |||
| 14ae5f1572 | |||
| ed40994600 | |||
| 90e8c35b19 | |||
| 4d017ad357 | |||
| 2ca2a9db23 | |||
| 713d46c781 | |||
| 0429665173 | |||
| ac05edca77 | |||
| ad3dacf68f | |||
| 91a8996c11 | |||
| 40c4a51a2b | |||
| f8e0aaf4d2 | |||
| ad97b5a406 | |||
| b0e0c1b75f | |||
| b1fce443e9 | |||
| 66f8711055 | |||
| b7c123c281 | |||
| 9481bbf3f1 | |||
| 43ec7e821b | |||
| ca3454afbe | |||
| 7c70e58129 | |||
| 1edc8aabf8 | |||
| 91060faac9 | |||
| 17027ba364 | |||
| 8569eaa5db | |||
| d32d817e0a | |||
| a0f4cc760c | |||
| 5247997ea5 | |||
| 453030d561 | |||
| e080702a52 | |||
| 3909343adc | |||
| dc76934d0e | |||
| 6cf47d592a | |||
| 1507c70729 | |||
| d6a23ac0de | |||
| 17df396672 | |||
| 0c5ba0cd39 | |||
| 183aeb18a0 | |||
| 8d08e19cd2 | |||
| a882d04d26 | |||
| c4d06c1ba2 | |||
| 4dfcd47901 | |||
| 4c0c1abb4b | |||
| 6f44071186 | |||
| 29910a2698 | |||
| b5da0d4462 | |||
| 99fb9b3462 | |||
| 5f0a89d13b | |||
| f311561e6f | |||
| 2fc944ddd9 | |||
| a2970b86ee | |||
| ac9a51f105 | |||
| 90dca2537a | |||
| 4df227147c | |||
| 1fb55dca0a | |||
| 3d7b347e49 | |||
| 769ec9f59a | |||
| dee310de3d | |||
| 0af4bad906 | |||
| 4731673ba3 | |||
| 8745221cbd | |||
| 742d95440e | |||
| 180b320cd7 | |||
| cc8dffc485 | |||
| a64fd2cf35 | |||
| 4aceb364d9 | |||
| 76d9bac0ec | |||
| 2b8dc41d0d | |||
| 33430c538c | |||
| 03e9cb398b | |||
| 2aef2ebec1 | |||
| 5e5fffbf97 | |||
| 51ac604e31 | |||
| 4e49b5bc63 | |||
| 658cbc5e00 | |||
| 2ceb4c5644 | |||
| 940bed2cee | |||
| 2738954af7 | |||
| db5aaf0b84 | |||
| e1abb7f8ae | |||
| 3310ac6008 | |||
| 09879c83e9 | |||
| 7aa8b6bc14 | |||
| cac8a8fde4 | |||
| 01cb544dfd | |||
| b9239b6177 | |||
| 96ca3f62a2 | |||
| 73ad783881 | |||
| 3bfcf65535 | |||
| 8b3b27a2a8 | |||
| a4d4835a89 | |||
| 56c0f7bfaf | |||
| 736424ae35 | |||
| 37dc778009 | |||
| cd3cea58a4 | |||
| 8b53e9e5e3 | |||
| 08e98b089c | |||
| 5528d71da8 | |||
| 83f520ca44 | |||
| cc247ce634 | |||
| c6caa59a90 | |||
| 00e28b9ce0 | |||
| 334f58979a | |||
| 940bf163da | |||
| 2bbe0e6133 | |||
| 861f34a287 | |||
| 82f214f155 | |||
| 4ee127fe13 | |||
| 86a4cf8d84 | |||
| 2c463dd5a1 | |||
| ed3820bec0 | |||
| 542a7f212d | |||
| 8fb0826d69 | |||
| deeaa55f56 | |||
| 5b954727a1 | |||
| fae77c1a63 | |||
| b69402dfe9 | |||
| 1f3e306a59 | |||
| a9605118fb | |||
| d22e918273 | |||
| bdcb94055a | |||
| d0644d39da | |||
| 8f3f776e22 | |||
| 548752e240 | |||
| 7f20250951 | |||
| 4d720b1d81 | |||
| 1e4aefb7d5 | |||
| 2a825a9f83 | |||
| a8921a1aba | |||
| edb9eda0a9 | |||
| 3a81676447 | |||
| 6695774037 | |||
| 03132ff77b | |||
| 49ddecdea4 | |||
| a10bc8c7de | |||
| c1e6e401cc | |||
| 44ff951ec6 | |||
| 11319e0ec5 | |||
| 100e98a960 | |||
| c6100ede70 | |||
| a2986a72bd | |||
| e0e90c5f74 | |||
| 11992af81b | |||
| 15d771f7fc | |||
| 5ede474253 | |||
| 7922aa6f80 | |||
| 0c1333fa15 | |||
| 53b9ba0368 | |||
| c3a8877796 | |||
| a464ae9df5 | |||
| 98b6213886 | |||
| b6671c653c | |||
| 55d042bee3 | |||
| 0d16dd0006 | |||
| 48a96140a7 | |||
| 603ef8f295 | |||
| ab07288ba0 | |||
| c0bbe5d491 | |||
| b953ff21e7 | |||
| c14378b534 | |||
| 80034ad131 | |||
| 33d3d9a29c | |||
| 7e83793586 | |||
| 6ba9ec8bc2 | |||
| 0b02ab0e2d | |||
| ff531b5e77 | |||
| b3f9de3b83 | |||
| 86bd71b89c | |||
| 2fca7e9a01 | |||
| 2cc873ef60 | |||
| 7a66ce6bcd | |||
| 2730569b6b | |||
| ede5c4409c | |||
| 0dbe398435 | |||
| bcab3bccbc | |||
| 58c9aeb1a2 | |||
| 4eb20a1843 | |||
| 98c6378148 | |||
| bb066a7a31 | |||
| b5d3261f03 | |||
| 4702787784 | |||
| 30c41044da | |||
| e369676808 | |||
| 2fa9e65bee | |||
| cf96bd1ec0 | |||
| 1f5a069877 | |||
| 13100dc38d | |||
| 5227041398 | |||
| 8491d4da1a | |||
| 9bea1563ca | |||
| adc5013ea4 | |||
| 9e7b936663 | |||
| 19c84475db | |||
| 515c5e00e9 | |||
| 09bc180d4f | |||
| 4164b1a3f8 | |||
| a9dc038190 | |||
| 2825db88a5 | |||
| 363099b303 | |||
| 5e25a5054f | |||
| 2bc6127f6b | |||
| 064824aedf | |||
| 52044edb2e | |||
| fb12073a82 | |||
| 9944842a2f | |||
| 99dc50894c | |||
| de39451f67 | |||
| 8f28653b28 | |||
| 6598dff6df | |||
| 389798457b | |||
| ba9f843368 | |||
| 623c47fa2e | |||
| 19861fe812 | |||
| dd1c04bea1 | |||
| e6159117f6 | |||
| 0d9e1cd3c5 | |||
| 10753eb879 | |||
| 29aec21095 | |||
| a810f82ce2 | |||
| 2c454a0ec5 | |||
| d3dca00482 | |||
| d08dffd9e2 | |||
| 5b50ac926e | |||
| 57a3be35d0 | |||
| 70f36e69e6 | |||
| 8e70f1b865 | |||
| f86fb0ee44 | |||
| fe0aac7c6e | |||
| b93447f712 | |||
| 84a5103526 | |||
| c333300906 | |||
| c94c2721d7 | |||
| 0428c1191a | |||
| 8208f92802 | |||
| 3d33c4b8e0 | |||
| d3210ec12a | |||
| c959b973fc | |||
| 40c195d4a0 | |||
| f4f1470153 | |||
| 401999b5ea | |||
| 7b53315046 | |||
| 4d170db5e0 | |||
| fa8d175101 | |||
| cbef605f22 | |||
| cf95791dcc | |||
| 919567dbdb | |||
| 8ca317a38a | |||
| ccc686ed50 | |||
| e3e7b0c345 | |||
| 5b0f359944 | |||
| 29f1bef099 | |||
| 0653f88c49 | |||
| 4ce9f64808 | |||
| 418f34c7e8 | |||
| 21c2ab21b2 | |||
| 1ace7318f3 | |||
| 48052b88db | |||
| 715c60dc6e | |||
| 916d052688 | |||
| 993b812c3b | |||
| 43887586b5 | |||
| 03d53f21a3 | |||
| 23d7e8e5b6 | |||
| cce117c585 | |||
| 303bd1b805 | |||
| c7f4a40342 | |||
| 208c6c0776 | |||
| 755bebaecb | |||
| 4fa0229ccb | |||
| 7d5c8347ce | |||
| bd70131252 | |||
| 43a373eceb | |||
| 5bb3466ffe | |||
| 75e97ed008 | |||
| ee28604c11 | |||
| a7d89e1bfb | |||
| cbfd9ea559 | |||
| dae50c3bc3 | |||
| e651e59dc4 | |||
| 80d78761bf | |||
| fb85aa4f32 | |||
| 9635c95efe | |||
| 033a237488 | |||
| ec22c58822 | |||
| 274942b5ba | |||
| 94ab3da0e4 | |||
| 5d44f0f2b6 | |||
| f051e6b452 | |||
| 46a4284253 | |||
| 0a708c6892 | |||
| 0f96164dc3 | |||
| 91c4917021 | |||
| c32ebe016b | |||
| ea26eefc2d | |||
| 418f4a6075 | |||
| 0ec921709a | |||
| e0811cfd93 | |||
| f6b0778eb6 | |||
| 18aec34c0e | |||
| bd185776e7 | |||
| fca5fe38bb | |||
| 1c2c7b376d | |||
| 670df86114 | |||
| 55fb4d4562 | |||
| c703d018bd | |||
| 425a27e130 | |||
| bd1b0e875b | |||
| 1509c11f64 | |||
| 57c1097fbc | |||
| 1d1728b92b | |||
| 8202513993 | |||
| 5f9f6dbde8 | |||
| cc3639180b | |||
| 8aa4de7522 | |||
| ed1f7e7c72 | |||
| 1ecd1f5e04 | |||
| 1aa9adc899 | |||
| f8b2da93b9 | |||
| b794ff47ef | |||
| 6962a0547a | |||
| b906c1d36b | |||
| af337b1874 | |||
| 542235cca0 | |||
| f5673425b7 | |||
| 94965cf3ba | |||
| 120ded5274 | |||
| 705eb6a3fa | |||
| 1eb62b31d2 | |||
| b145187fa8 | |||
| 4da1e44fd1 | |||
| 4e70279982 | |||
| 233c8ee26e | |||
| 875adb4d79 | |||
| 456514c4d4 | |||
| dac1918b95 | |||
| 1d7429ad86 | |||
| 5d0e6615ab | |||
| dc415df8c0 | |||
| 45ce251c4c | |||
| 2bc702112f | |||
| abd73bf797 | |||
| e7e67b9572 | |||
| 1a58b693c1 | |||
| 50ecb909b4 | |||
| 5e480be8db | |||
| 48a67e51a6 | |||
| 5052bad824 | |||
| 5be92052bb | |||
| e20945692e | |||
| 191a6e2460 | |||
| c813fb4fad | |||
| bf7001b578 | |||
| 18102a2a73 | |||
| 780c1dbde1 | |||
| 879aab0d99 | |||
| 6f37bc2f5d | |||
| fc59b841d6 | |||
| c07fcdd489 | |||
| a49db10ade | |||
| 77bae98d77 | |||
| 254df7211c | |||
| f9caab48c4 | |||
| e0b5e7b808 | |||
| ac3a8da002 | |||
| 1aa45c2156 | |||
| 3cf8abd409 | |||
| db8426779c | |||
| b419e033f3 | |||
| d686fa327b | |||
| a1ce5eda43 | |||
| 1e790d1aa9 | |||
| d1d304b758 | |||
| e12b500144 | |||
| bd77651a1e | |||
| 35dc186395 | |||
| 07e78e0d12 | |||
| 5b8905c1d2 | |||
| 158a27cbae | |||
| 5769b39d78 | |||
| 5c96262c75 | |||
| 766f57dc9d | |||
| 9986078582 | |||
| e047ab5684 | |||
| a100785ad7 | |||
| 156eb4d15e | |||
| dabcfd965f | |||
| d44a71f3be | |||
| f8edd6cf3d | |||
| 2baf53c5a4 | |||
| c26e9c281f | |||
| 9f78e9b7dd | |||
| fdaf41b605 | |||
| 89526efe7a | |||
| 5e3a25c18f | |||
| cf11c4283e | |||
| 2dde04b979 | |||
| 8384f227be | |||
| 697b3bc5f5 | |||
| 9e2041521e | |||
| ee7b89ec6e | |||
| 5b143bdc76 | |||
| e3800426c9 | |||
| d9d00e452e | |||
| 14500e281c | |||
| c4623c80ff | |||
| 9e17dce9a9 | |||
| 4acc867634 | |||
| 1a061268de | |||
| daa91986ef | |||
| 63761cfc9a | |||
| 5091a5485a | |||
| d10026acd1 | |||
| f8f1cababe | |||
| ad46841397 | |||
| 20fb1e0fd0 | |||
| 9347351c37 | |||
| 42dd8d6152 | |||
| 0ef1f2d40f | |||
| b460f9915d | |||
| 4e195dfbc3 | |||
| 38b9fe3017 | |||
| 76a42f5f6f | |||
| 0a839b4814 | |||
| 3c7f7bfca7 | |||
| 05230971b3 | |||
| 586db317dd | |||
| ae36a24ad1 | |||
| dccdf72c73 | |||
| ca15983a72 | |||
| 4b6a2c9829 | |||
| 9a435f8859 | |||
| 81162c5df2 | |||
| c7c3ddfc96 | |||
| 830d3a9022 | |||
| a1c2d19daf | |||
| 1755d03a6b | |||
| 869b1fc15e | |||
| ce2a2f8582 | |||
| 7b355139fb | |||
| b14518edb1 | |||
| 7d64003d1c | |||
| 0a59e04f19 | |||
| b57abb646f | |||
| dd6bde97a9 | |||
| 004e4be4d3 | |||
| bd87a47551 | |||
| b545545712 | |||
| c1993ffa03 | |||
| 7f7ebafa46 | |||
| b652597924 | |||
| 258fe77928 | |||
| 5a9fcd6fab | |||
| 3c05521a5b | |||
| 034b8b15ae | |||
| 7bd687331b | |||
| bdae35b1a8 | |||
| 54d58df4b6 | |||
| 76103a2a8c | |||
| 9165a9f7cb | |||
| b556d1e81d | |||
| 7c25678211 | |||
| f63f9dd6db | |||
| c83a9924e2 | |||
| bbeb9b83a0 | |||
| 06478f3e36 | |||
| 40f20002b2 | |||
| 442272f517 | |||
| 88dae8e9c4 | |||
| 1bbfa7d39e | |||
| edc2b3d295 | |||
| 0006da7385 | |||
| 470b7bd2e5 | |||
| 9014fb581d | |||
| b5ac8b3ec6 | |||
| 78f5169880 | |||
| 7ffa6b1bb3 | |||
| 3cd4b4503f | |||
| 3361b77aec | |||
| 8b7c9df286 | |||
| 157d5b4c36 | |||
| 44c8800bec | |||
| 2f0ba1b1f7 | |||
| 36c51f1a0c | |||
| 1dfe18aa6f | |||
| b9bbfb44c5 | |||
| 83843f192d | |||
| 8839d9f1c6 | |||
| 0630ec1d46 | |||
| 4dce8d6a80 | |||
| 3b62f999bf | |||
| d63fa521a1 | |||
| ca781dfe15 | |||
| 4bc561ceab | |||
| 3d258180bd | |||
| 65ae8610fd | |||
| c1c2000c98 | |||
| 287c2d82a1 | |||
| 5cde1650f4 | |||
| a4b90f14ab | |||
| 4826b40136 | |||
| 62618224da | |||
| 49f15e1637 | |||
| d5cab0910e | |||
| d4ccf232c1 | |||
| e36047c890 | |||
| 8f1199bd08 | |||
| d6e045ea4e | |||
| 304e48996b | |||
| f350dc83b8 | |||
| ebb7beda8c | |||
| a01f3da66e | |||
| 72f5b5fbc0 | |||
| 330aa495c8 | |||
| 0b529ae94d | |||
| 83b35183d0 | |||
| daf1d42a0f | |||
| 2cd01eb1fe | |||
| 07378f665a | |||
| bfd5f24f4c | |||
| 3d617187af | |||
| d040b93ca9 | |||
| a1d460385d | |||
| a410e2962a | |||
| f5aa8f37bb | |||
| 7e932df450 | |||
| 3d4741727e | |||
| a03b63ef74 | |||
| 15ce3e9f20 | |||
| d2ed0c65ca | |||
| da58b72f9d | |||
| 1639bd7af1 | |||
| d474121f85 | |||
| 978f76ffb6 | |||
| 084bac00f5 | |||
| 94454172dd | |||
| 891d3cf966 | |||
| 561d5ec7ab | |||
| 7ce437d50a | |||
| 4b02d4ce90 | |||
| 3107185869 | |||
| 2e3584a353 | |||
| e5b1be195c | |||
| dde30c9d76 | |||
| 3830e65de8 | |||
| c589cf167e | |||
| 2fde367c82 | |||
| 8fd188268e | |||
| b65257df42 | |||
| aaa2d7f08d | |||
| f73e25ece6 | |||
| 78d427f208 | |||
| eaeaf3538f | |||
| 85e381a85e | |||
| 1b7ee8231b | |||
| 1b8b8f5738 | |||
| 53df19b477 | |||
| ccf21b7580 | |||
| 4189d62a57 | |||
| 9a3e3af614 | |||
| f7187400dc | |||
| f55a7f0a7b | |||
| d6d35a645e | |||
| e719dcc7f5 | |||
| bc5bc5450c | |||
| f4bade0c2e | |||
| 9be59c674d | |||
| a1dec23c20 | |||
| ed926c4e37 | |||
| ab360ed6f6 | |||
| 569ba3d651 | |||
| 60fe28c2fe | |||
| 2787e29a07 | |||
| c77a4d08d6 | |||
| 9b3f90f922 | |||
| c88d457021 | |||
| b20b625820 | |||
| fd95311920 | |||
| 6da5c11731 | |||
| 4e58231308 | |||
| ef0ecf249a | |||
| 4981617f7a | |||
| 2070bc7007 | |||
| 231d2461b3 | |||
| 3b457f87c4 | |||
| de3ced4d3c | |||
| 891777e89e | |||
| 287239dd1c | |||
| 7cdded8fd7 | |||
| 8c9d045e1d | |||
| 620f5a0459 | |||
| 178d874ba0 | |||
| d44f30c8a6 | |||
| ce66937429 | |||
| 9823337375 | |||
| 11f5f0dfe1 | |||
| e1882f19e8 | |||
| 6a8b9f06c2 | |||
| 752fc8787d | |||
| 90a1cd8280 | |||
| aa570ac29d | |||
| fb7b6363f9 | |||
| 23afe7994c | |||
| 7557e6f6ba | |||
| 86b6938911 | |||
| 8f30a45fa8 | |||
| 7c9e9d5f52 | |||
| 4066ce73a8 | |||
| b5722dba1a | |||
| 81765ecafc | |||
| 84b42e9d19 | |||
| ed319a0e5f | |||
| dd55d10194 | |||
| 2084b46090 | |||
| 53443a6cf2 | |||
| 92715b5642 | |||
| 6166392515 | |||
| 49d0dead7d | |||
| 6f004830ff | |||
| e2e5e36bad | |||
| f267d264d3 | |||
| be1a77bfd7 | |||
| 41a980e826 | |||
| 09c09f3d64 | |||
| 2404399ec5 | |||
| b45d4c0557 | |||
| a41b138d3c | |||
| 1e46949dd6 | |||
| 3ed2c1ba5d | |||
| 809b99c9c9 | |||
| 4d3acdb5fb | |||
| ca9e321ef2 | |||
| da27517fcf | |||
| 192df0a3b8 | |||
| a965003a9d | |||
| 9ea26c821f | |||
| 14b699485a | |||
| 1684edc43f | |||
| 580c4418b9 | |||
| 4a65fc2358 | |||
| 71ba131fb3 | |||
| 9693b50719 | |||
| 102e2c54bb | |||
| e989590c08 | |||
| 6cee33b449 | |||
| f32498a444 | |||
| c85f71b601 | |||
| 196e55899e | |||
| ebec45076d | |||
| 561d9ae987 | |||
| 8950bd94cb | |||
| f416f197bc | |||
| 65afe5a0e6 | |||
| 4b5d347413 | |||
| 4dcc2dd0ca | |||
| 2a7a332160 | |||
| 27ee1eabda | |||
| 0034665965 | |||
| a69692be18 | |||
| dc76152166 | |||
| d7f3ae696c | |||
| 71f5449d34 | |||
| 0e64fa8d4c | |||
| 73b048d4c5 | |||
| 1c05b39861 | |||
| 7cfa6c163f | |||
| 2d4af2e867 | |||
| 1eeaffc442 | |||
| 82125b33ed | |||
| 42cbbc28fd | |||
| a7cbb0e93c | |||
| fde6148ece | |||
| df1661d75a | |||
| f938f79a35 | |||
| 333f00235b | |||
| c06475bfb3 | |||
| d1a54d0cf3 | |||
| 349437c06b | |||
| 1b03c83c84 | |||
| bb749aacf1 | |||
| 3a41b89e52 | |||
| 70cbc77381 | |||
| 3a99f5dfaa | |||
| f24435ecf4 | |||
| 4a708e316a | |||
| c2b47c998d | |||
| 534f7b3134 | |||
| d5d2692317 | |||
| dc9cc7b00f | |||
| 965e74c7e2 | |||
| 096ba54eb1 | |||
| f4e38f9e50 | |||
| c0d9409176 | |||
| 7d1f565749 | |||
| dfec4ada3b | |||
| cd695cf265 | |||
| 47ff2e0c38 | |||
| db7c09291f | |||
| 01f10c49ba | |||
| 1ff0692a72 | |||
| 116e6099d5 | |||
| 18ccaadc5b | |||
| 8f6eac7ca2 | |||
| f4610d0df5 | |||
| bf1a6b7d0a | |||
| b3fd05e62e | |||
| f7ce365618 | |||
| 77a558dbe5 | |||
| cc0c400b28 | |||
| 2bcd59cbfa | |||
| 5139acc7f1 | |||
| 1564433e02 | |||
| 1339beb7cd | |||
| cd9698ea48 | |||
| c8f8e4c5eb | |||
| 0b4ab46563 | |||
| ea1ac86134 | |||
| 790331e798 | |||
| f5d9b2ba41 | |||
| 7f26ac00b1 | |||
| fcbab10434 | |||
| c4061cc6ac | |||
| 12ac4d6b6f | |||
| 3d06e62cd4 | |||
| d7d23e1048 | |||
| 1fe9b70176 | |||
| a9cf8dd71a | |||
| 3299261db3 | |||
| e465ec8278 | |||
| d0e4a0aa1f | |||
| 74efec3235 | |||
| 13516087f2 | |||
| 0a0c16524a | |||
| 9b843a155e | |||
| cb085acbff | |||
| c3d7df166b | |||
| d312062125 | |||
| e2453192aa | |||
| 68eb0cc8f2 | |||
| cb9cecfa5d | |||
| 0f4e4a7d97 | |||
| f20a708b36 | |||
| 8c4e511883 | |||
| a4a3b8d664 | |||
| bf6530ea81 | |||
| 4a80c2aab1 | |||
| 527bbfe43f | |||
| d8e1edb60b | |||
| 245b5f74c0 | |||
| e9a1f63415 | |||
| ec370dd94b | |||
| e39d862ef3 | |||
| 7b065654aa | |||
| 918b2bbe96 | |||
| e529a3d34d | |||
| 5475778d67 | |||
| c6a3ff0a53 | |||
| cf3587f504 | |||
| d42f104884 | |||
| 6a43568369 | |||
| 85c9cd0a6e | |||
| be5920cfae | |||
| 3d25d94a77 | |||
| fe97850835 | |||
| dab9decd89 | |||
| 854651aa71 | |||
| fdd1af3287 | |||
| 0bf92b6aff | |||
| d9403bf4da | |||
| 716d8caf4d | |||
| 0f0f368a75 | |||
| ff8d7558d4 | |||
| 66f9824b68 | |||
| 44a6e5da38 | |||
| de5a4aa5f3 | |||
| e8007082a7 | |||
| 3c70c5a366 | |||
| eb6e79b055 | |||
| ea59f8dccb | |||
| aef1c584e5 | |||
| c4ce671a87 | |||
| e8a79c87ab | |||
| 249e77a5d3 | |||
| 3cf4a52a69 | |||
| eb8b02756b | |||
| 0510d34ed3 | |||
| 1c8d12e72a | |||
| 0a36a6b674 | |||
| b887c9d50f | |||
| ee4e108e4f | |||
| 5e14a0fed4 | |||
| 6045205ea9 | |||
| f2d763cdec | |||
| e5e348205a | |||
| af6d219936 | |||
| 82a07e2e09 | |||
| 12a9b99fff | |||
| 3adf761158 | |||
| 670a4c61ff | |||
| 220f50d3bb | |||
| e0bf9d2a7c | |||
| f61cf46a52 | |||
| d188128d27 | |||
| f698c4120d | |||
| 338a852d49 | |||
| a64ee2242c | |||
| e9ff5e6f0b | |||
| f3911d8b68 | |||
| 9ce0be6450 | |||
| 6ab3eff61c | |||
| 0281da1c5a | |||
| 0b4770188c | |||
| 9376bb05fa | |||
| ecca3b6793 | |||
| f41a971cd8 | |||
| 44ba66d619 | |||
| bf685a607f | |||
| 5713cf0508 | |||
| bdd50d70ca | |||
| 8188399ce6 | |||
| f72b7dbbbb | |||
| 2409afcc5c | |||
| 15c0d02c13 | |||
| a54a5081e6 | |||
| db9dfcf049 | |||
| 47f9948748 | |||
| 05e866df55 | |||
| fc431f0cb8 | |||
| 228ab359ed | |||
| 103a8587f7 | |||
| 7db0083928 | |||
| e6f6ab499a | |||
| 721b7dbba0 | |||
| a95ddab814 | |||
| 2941546ae4 | |||
| bd9b9179c1 | |||
| ce7d54c151 | |||
| 3c778c07c2 | |||
| 95207341db | |||
| 70cf24924d | |||
| a8ebba691e | |||
| ec19ea44ad | |||
| ca8dc0f0f5 | |||
| 1dc50a697c | |||
| 1167c314ee | |||
| 55781e2b34 | |||
| 7439e44e44 | |||
| cf2639df3d | |||
| 834de928c2 | |||
| 72efb21439 | |||
| aa8790ebdb | |||
| 6d491052ee | |||
| 87ff4691ce | |||
| 34d76e79ed | |||
| 31b43da96f | |||
| 0540e673e2 | |||
| 4e88a63809 | |||
| f7581f8a65 | |||
| e87a1c079c | |||
| 3f9477c246 | |||
| 05ed1e188e | |||
| f3d06e49f8 | |||
| f9a4b68967 | |||
| 3631cfe365 | |||
| da6eef905c | |||
| 8766ae176e | |||
| 36b53d490f | |||
| f9b8b812a4 | |||
| ac9eae5272 |
@@ -0,0 +1,6 @@
|
|||||||
|
aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/aar/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/arm64-v8a filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/armeabi-v7a filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/x86 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
app/src/main/jniLibs/x86_64 filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
||||||
|
labels: ["Bug", "Android"]
|
||||||
|
title: "Bug: "
|
||||||
|
type: bug
|
||||||
|
projects: ["futo-org/19"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
# Thank you for taking the time to fill out this bug report.
|
||||||
|
|
||||||
|
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
||||||
|
|
||||||
|
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||||
|
|
||||||
|
## Filing a bug report
|
||||||
|
|
||||||
|
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
||||||
|
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
|
||||||
|
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction-steps
|
||||||
|
attributes:
|
||||||
|
label: Reproduction steps
|
||||||
|
description: Please provide us with the steps to reproduce the issue if possible. This step makes a big difference if we are going to be able to fix it so be as precise as possible.
|
||||||
|
placeholder: |
|
||||||
|
0. Play a YouTube video
|
||||||
|
1. Press on Download button
|
||||||
|
2. Select quality 1440p
|
||||||
|
3. Grayjay crashes when attempting to download
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual-result
|
||||||
|
attributes:
|
||||||
|
label: Actual result
|
||||||
|
description: What happend?
|
||||||
|
placeholder: Tell us what you saw!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected-result
|
||||||
|
attributes:
|
||||||
|
label: Expected result
|
||||||
|
description: What was suppose to happen?
|
||||||
|
placeholder: Tell us what you expected to happen!
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: grayjay-version
|
||||||
|
attributes:
|
||||||
|
label: Grayjay Version
|
||||||
|
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
||||||
|
placeholder: "311"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: plugin
|
||||||
|
attributes:
|
||||||
|
label: What plugins are you seeing the problem on?
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- "All"
|
||||||
|
- "Apple Podcasts"
|
||||||
|
- "BiliBili (CN)"
|
||||||
|
- "Bitchute"
|
||||||
|
- "Crunchyroll"
|
||||||
|
- "CuriosityStream"
|
||||||
|
- "Dailymotion"
|
||||||
|
- "Kick"
|
||||||
|
- "Nebula"
|
||||||
|
- "Odysee"
|
||||||
|
- "Patreon"
|
||||||
|
- "PeerTube"
|
||||||
|
- "Rumble"
|
||||||
|
- "SoundCloud"
|
||||||
|
- "Spotify"
|
||||||
|
- "TedTalks"
|
||||||
|
- "Twitch"
|
||||||
|
- "YouTube"
|
||||||
|
- "Other"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: plugin-version
|
||||||
|
attributes:
|
||||||
|
label: Plugin Version
|
||||||
|
description: In the application, select Sources > [the broken plugin], write down the value under "Version".
|
||||||
|
placeholder: "12"
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: android-version
|
||||||
|
attributes:
|
||||||
|
label: Which android version are you using?
|
||||||
|
placeholder: "Android 15"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: phone-model
|
||||||
|
attributes:
|
||||||
|
label: Which device are you using?
|
||||||
|
placeholder: "Google Pixel 9"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: os-version
|
||||||
|
attributes:
|
||||||
|
label: Which operating system are you using?
|
||||||
|
placeholder: "GrapheneOS/CalyxOS/Tizen/HyperOS 2/..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: login
|
||||||
|
attributes:
|
||||||
|
label: When do you experience the issue?
|
||||||
|
options:
|
||||||
|
- label: While logged in
|
||||||
|
- label: While logged out
|
||||||
|
- label: N/A
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: vpn
|
||||||
|
attributes:
|
||||||
|
label: Are you using a VPN?
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- "No"
|
||||||
|
- "Yes"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: grayjay-references
|
||||||
|
attributes:
|
||||||
|
label: References
|
||||||
|
description: |
|
||||||
|
Are there any other GitHub issues, whether open or closed, that are related to the problem you've described above? If so, please create a list below that mentions each of them. For example:
|
||||||
|
```
|
||||||
|
- #10
|
||||||
|
```
|
||||||
|
placeholder:
|
||||||
|
value:
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
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
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||||
+6
-5
@@ -1,15 +1,16 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Suggest a new feature or other enhancement.
|
description: Suggest a new feature or other enhancement.
|
||||||
labels: ["enhancement", "new"]
|
labels: ["Enhancement", "Android"]
|
||||||
|
title: "Feature request: "
|
||||||
|
type: feature
|
||||||
|
projects: ["futo-org/19"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
# Thank you for opening a feature request.
|
# Thank you for opening a feature request.
|
||||||
|
|
||||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues and feature requests relating to the Grayjay android application
|
||||||
|
|
||||||
[External Contributions are closed at this time](https://github.com/tom-futo/grayjay-android/blob/master/CONTRIBUTION.md#contributing-to-core)
|
|
||||||
|
|
||||||
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
For discussion related to enhancements, please see: [The FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||||
|
|
||||||
@@ -55,4 +56,4 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
**Note:** If the submit button is disabled and you have filled out all required fields, please check that you did not forget a **Title** for the issue.
|
||||||
|
|
||||||
+5
-2
@@ -1,13 +1,16 @@
|
|||||||
name: Documentation Issue
|
name: Documentation Issue
|
||||||
description: Report an issue or suggest a change in the documentation.
|
description: Report an issue or suggest a change in the documentation.
|
||||||
labels: ["documentation", "new"]
|
labels: ["Documentation"]
|
||||||
|
title: "Documentation: "
|
||||||
|
type: task
|
||||||
|
projects: ["futo-org/19"]
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
# Thank you for opening a documentation change request.
|
# Thank you for opening a documentation change request.
|
||||||
|
|
||||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay android application. Use the `Documentation` issue type to report problems with the documentation in our code repositories, inside the application, or on [https://grayjay.app](https://grayjay.app)
|
||||||
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
Technical writers monitor this issue type, so report Grayjay bugs or feature requests with the `Bug report` or `Feature Request` issue types instead to get engineering attention.
|
||||||
|
|
||||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
name: Bug Report
|
|
||||||
description: Let us know about an unexpected error, a crash, or an incorrect behavior.
|
|
||||||
labels: ["bug", "new"]
|
|
||||||
body:
|
|
||||||
- type: markdown
|
|
||||||
attributes:
|
|
||||||
value: |
|
|
||||||
# Thank you for taking the time to fill out this bug report.
|
|
||||||
|
|
||||||
The [grayjay-android](https://github.com/futo-org/grayjay-android) issue tracker is reserved for issues relating to the Grayjay Android Application
|
|
||||||
|
|
||||||
For general usage questions, please see: [The Official FUTO Grayjay Zulip Channel](https://chat.futo.org/#narrow/stream/46-Grayjay)
|
|
||||||
|
|
||||||
## Filing a bug report
|
|
||||||
|
|
||||||
To fix your issues faster, we need clear reproduction cases - ideally allowing us to make it happen locally.
|
|
||||||
* Please include all needed context. For example, Device, OS, Application, your Grayjay Configurations and Plugin versioning info.
|
|
||||||
* if you've found out a particular series of UI interactions can introduce buggy behavior, please label those steps 1-n with markdown
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened?
|
|
||||||
description: What did you expect to happen?
|
|
||||||
placeholder: Tell us what you see!
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: grayjay-version
|
|
||||||
attributes:
|
|
||||||
label: Grayjay Version
|
|
||||||
description: In the application, select More > Settings, scroll to the bottom and locate the value next to "Version Name".
|
|
||||||
placeholder: "242"
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: dropdown
|
|
||||||
id: plugin
|
|
||||||
attributes:
|
|
||||||
label: What plugins are you seeing the problem on?
|
|
||||||
multiple: true
|
|
||||||
options:
|
|
||||||
- All
|
|
||||||
- Youtube
|
|
||||||
- BiliBili (CN)
|
|
||||||
- Twitch
|
|
||||||
- Odysee
|
|
||||||
- Rumble
|
|
||||||
- Kick
|
|
||||||
- PeerTube
|
|
||||||
- Patreon
|
|
||||||
- Nebula
|
|
||||||
- SoundCloud
|
|
||||||
- Other
|
|
||||||
validations:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
- type: input
|
|
||||||
id: plugin-version
|
|
||||||
attributes:
|
|
||||||
label: Plugin Version
|
|
||||||
description: In the application, select Sources > [the broken plugin], write down the value under "Version".
|
|
||||||
placeholder: "12"
|
|
||||||
|
|
||||||
- type: checkboxes
|
|
||||||
id: login
|
|
||||||
attributes:
|
|
||||||
label: When do you experience the issue?
|
|
||||||
options:
|
|
||||||
- label: While logged in
|
|
||||||
- label: While logged out
|
|
||||||
- label: N/A
|
|
||||||
|
|
||||||
- type: textarea
|
|
||||||
id: logs
|
|
||||||
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
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: Issue labeler
|
|
||||||
on:
|
|
||||||
issues:
|
|
||||||
types: [ opened ]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
label-component:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
# required for all workflows
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- name: Parse issue form
|
|
||||||
uses: stefanbuck/github-issue-parser@v3
|
|
||||||
id: issue-parser
|
|
||||||
with:
|
|
||||||
template-path: .github/ISSUE_TEMPLATE/bug_report.yml
|
|
||||||
|
|
||||||
- name: Set labels based on plugin field
|
|
||||||
uses: redhat-plumbers-in-action/advanced-issue-labeler@v2
|
|
||||||
with:
|
|
||||||
issue-form: ${{ steps.issue-parser.outputs.jsonString }}
|
|
||||||
section: plugin
|
|
||||||
block-list: |
|
|
||||||
None
|
|
||||||
Other
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
+43
-16
@@ -1,37 +1,64 @@
|
|||||||
variables:
|
variables:
|
||||||
GIT_SUBMODULE_STRATEGY: recursive
|
GIT_SUBMODULE_STRATEGY: recursive
|
||||||
|
|
||||||
stages:
|
|
||||||
- buildAndDeployApkUnstable
|
|
||||||
- buildAndDeployApkStable
|
|
||||||
- buildAndDeployPlaystore
|
|
||||||
|
|
||||||
buildAndDeployApkUnstable:
|
buildAndDeployApkUnstable:
|
||||||
stage: buildAndDeployApkUnstable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-unstable.sh
|
- sh deploy-unstable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- ^(dev)
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
allow_failure: true
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/unstable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployApkStable:
|
buildAndDeployApkStable:
|
||||||
stage: buildAndDeployApkStable
|
stage: build
|
||||||
script:
|
script:
|
||||||
- sh deploy-stable.sh
|
- sh deploy-stable.sh
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
|
||||||
- branches
|
|
||||||
when: manual
|
when: manual
|
||||||
|
needs: []
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/apk/stable/release/*.apk
|
||||||
|
|
||||||
buildAndDeployPlaystore:
|
buildAndDeployPlaystore:
|
||||||
stage: buildAndDeployPlaystore
|
stage: deploy
|
||||||
script:
|
script:
|
||||||
- sh deploy-playstore.sh
|
- sh build-playstore.sh
|
||||||
|
- bash venv-playstore.sh
|
||||||
|
- . .venv-playstore/bin/activate
|
||||||
|
- python publish_playstore.py --sa /root/grayjay.json --package com.futo.platformplayer.playstore --aab ./app/build/outputs/bundle/playstoreRelease/app-playstore-release.aab --track production --status completed
|
||||||
only:
|
only:
|
||||||
- tags
|
- tags
|
||||||
except:
|
when: on_success
|
||||||
- branches
|
needs:
|
||||||
when: manual
|
- buildAndDeployApkStable
|
||||||
|
artifacts:
|
||||||
|
when: always
|
||||||
|
expire_in: 30 days
|
||||||
|
paths:
|
||||||
|
- app/build/outputs/bundle/playstoreRelease/*.aab
|
||||||
|
|
||||||
|
updateFdroidRepo:
|
||||||
|
stage: deploy
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
when: on_success
|
||||||
|
needs:
|
||||||
|
- job: buildAndDeployApkStable
|
||||||
|
artifacts: true
|
||||||
|
before_script:
|
||||||
|
- mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||||
|
- touch ~/.ssh/known_hosts && chmod 644 ~/.ssh/known_hosts
|
||||||
|
- ssh-keygen -F gitlab.futo.org >/dev/null 2>&1 || ssh-keyscan -t rsa,ecdsa,ed25519 gitlab.futo.org >> ~/.ssh/known_hosts
|
||||||
|
script:
|
||||||
|
- python3 update_fdroid_index.py
|
||||||
|
|||||||
+60
-6
@@ -64,9 +64,63 @@
|
|||||||
[submodule "app/src/stable/assets/sources/bilibili"]
|
[submodule "app/src/stable/assets/sources/bilibili"]
|
||||||
path = app/src/stable/assets/sources/bilibili
|
path = app/src/stable/assets/sources/bilibili
|
||||||
url = ../plugins/bilibili.git
|
url = ../plugins/bilibili.git
|
||||||
[submodule "app/src/stable/assets/sources/spotify"]
|
[submodule "app/src/stable/assets/sources/bitchute"]
|
||||||
path = app/src/stable/assets/sources/spotify
|
path = app/src/stable/assets/sources/bitchute
|
||||||
url = ../plugins/spotify.git
|
url = ../plugins/bitchute.git
|
||||||
[submodule "app/src/unstable/assets/sources/spotify"]
|
[submodule "app/src/unstable/assets/sources/bitchute"]
|
||||||
path = app/src/unstable/assets/sources/spotify
|
path = app/src/unstable/assets/sources/bitchute
|
||||||
url = ../plugins/spotify.git
|
url = ../plugins/bitchute.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/dailymotion"]
|
||||||
|
path = app/src/unstable/assets/sources/dailymotion
|
||||||
|
url = ../plugins/dailymotion.git
|
||||||
|
[submodule "app/src/stable/assets/sources/dailymotion"]
|
||||||
|
path = app/src/stable/assets/sources/dailymotion
|
||||||
|
url = ../plugins/dailymotion.git
|
||||||
|
[submodule "app/src/stable/assets/sources/apple-podcast"]
|
||||||
|
path = app/src/stable/assets/sources/apple-podcasts
|
||||||
|
url = ../plugins/apple-podcasts.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/apple-podcasts"]
|
||||||
|
path = app/src/unstable/assets/sources/apple-podcasts
|
||||||
|
url = ../plugins/apple-podcasts.git
|
||||||
|
[submodule "app/src/stable/assets/sources/tedtalks"]
|
||||||
|
path = app/src/stable/assets/sources/tedtalks
|
||||||
|
url = ../plugins/tedtalks.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/tedtalks"]
|
||||||
|
path = app/src/unstable/assets/sources/tedtalks
|
||||||
|
url = ../plugins/tedtalks.git
|
||||||
|
[submodule "app/src/stable/assets/sources/curiositystream"]
|
||||||
|
path = app/src/stable/assets/sources/curiositystream
|
||||||
|
url = ../plugins/curiositystream.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/curiositystream"]
|
||||||
|
path = app/src/unstable/assets/sources/curiositystream
|
||||||
|
url = ../plugins/curiositystream.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/crunchyroll"]
|
||||||
|
path = app/src/unstable/assets/sources/crunchyroll
|
||||||
|
url = ../plugins/crunchyroll.git
|
||||||
|
[submodule "app/src/stable/assets/sources/crunchyroll"]
|
||||||
|
path = app/src/stable/assets/sources/crunchyroll
|
||||||
|
url = ../plugins/crunchyroll.git
|
||||||
|
[submodule "app/src/stable/assets/sources/mixcloud"]
|
||||||
|
path = app/src/stable/assets/sources/mixcloud
|
||||||
|
url = ../plugins/mixcloud.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/mixcloud"]
|
||||||
|
path = app/src/unstable/assets/sources/mixcloud
|
||||||
|
url = ../plugins/mixcloud.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/radiobrowser"]
|
||||||
|
path = app/src/unstable/assets/sources/radiobrowser
|
||||||
|
url = ../plugins/radiobrowser.git
|
||||||
|
[submodule "app/src/stable/assets/sources/radiobrowser"]
|
||||||
|
path = app/src/stable/assets/sources/radiobrowser
|
||||||
|
url = ../plugins/radiobrowser.git
|
||||||
|
[submodule "app/src/stable/assets/sources/redbull-tv"]
|
||||||
|
path = app/src/stable/assets/sources/redbull-tv
|
||||||
|
url = ../plugins/redbull-tv.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/redbull-tv"]
|
||||||
|
path = app/src/unstable/assets/sources/redbull-tv
|
||||||
|
url = ../plugins/redbull-tv.git
|
||||||
|
[submodule "app/src/unstable/assets/sources/fosdem"]
|
||||||
|
path = app/src/unstable/assets/sources/fosdem
|
||||||
|
url = ../plugins/fosdem.git
|
||||||
|
[submodule "app/src/stable/assets/sources/fosdem"]
|
||||||
|
path = app/src/stable/assets/sources/fosdem
|
||||||
|
url = ../plugins/fosdem.git
|
||||||
|
|||||||
+16
-2
@@ -49,9 +49,23 @@ We encourage developers to write their own plugins. Please refer to the "Getting
|
|||||||
|
|
||||||
## Contributing to Core
|
## Contributing to Core
|
||||||
|
|
||||||
**We are currently not accepting contributions to the core.**
|
|
||||||
|
|
||||||
The core is currently licensed under the FUTO Temporary License (FTL). The licensing and ownership of contributions to the core are complex topics that we are still working on. We'll update these guidelines when we have more clarity.
|
### License
|
||||||
|
|
||||||
|
The core is currently licensed under the [Source First License 1.1](./LICENSE.md). All contributors have to sign FUTO Individual Contributor License Agreement before contributions can be accepted. You can read more about it at [https://cla.futo.org/](https://cla.futo.org/).
|
||||||
|
|
||||||
|
### How to Contribute
|
||||||
|
|
||||||
|
1. Fork the core repository.
|
||||||
|
2. Clone your fork.
|
||||||
|
3. Make your changes.
|
||||||
|
4. Commit and push your changes.
|
||||||
|
5. Open a pull request.
|
||||||
|
|
||||||
|
### Guidelines
|
||||||
|
|
||||||
|
- Ensure your code adheres to the existing style.
|
||||||
|
- Include documentation and unit tests (where applicable).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
# Grayjay Core License 1.0
|
# Source First License 1.1
|
||||||
|
|
||||||
## Acceptance
|
## Acceptance
|
||||||
By using the software, you agree to all of the terms and conditions below.
|
By using the software, you agree to all of the terms and conditions below.
|
||||||
@@ -16,7 +16,7 @@ Notwithstanding the above, you may not remove or obscure any functionality in th
|
|||||||
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law.
|
You may not alter, remove, or obscure any licensing, copyright, or other notices of the Licensor in the software. Any use of the Licensor’s trademarks is subject to applicable law.
|
||||||
|
|
||||||
## Patents
|
## Patents
|
||||||
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
If you make any written claim that the software infringes or contributes to infringement of any patent, your license for the software granted under these terms ends immediately. If your company makes such a claim, your license ends immediately for work on behalf of your company.
|
||||||
|
|
||||||
## Notices
|
## Notices
|
||||||
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
|
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms. If you modify the software, you must include in any modified copies of the software a prominent notice stating that you have modified the software, such as but not limited to, a statement in a readme file or an in-application about section.
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ technologies that frustrate centralization and industry consolidation.
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/video.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/video.png" height="700" /></b></td>
|
||||||
<td><b style="font-size:30px"><img src="images/video-details.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/video-details.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Video</td>
|
<td>Video</td>
|
||||||
@@ -24,12 +24,10 @@ The FUTO media app is a player that exposes multiple video websites as sources i
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/sources.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/source.png" height="700" /></b></td>
|
||||||
<td><b style="font-size:30px"><img src="images/sources-disabled.jpg" height="700" /></b></td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Sources (all enabled)</td>
|
<td>Sources</td>
|
||||||
<td>Sources (one disabled)</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -38,7 +36,7 @@ Additional sources can also be installed. These sources are JavaScript sources,
|
|||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/source-install.png" height="700" /></b></td>
|
||||||
<td><b style="font-size:30px"><img src="images/source-settings.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/source-settings.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Install a new source</td>
|
<td>Install a new source</td>
|
||||||
@@ -54,8 +52,8 @@ When a user enters a search term into the search bar, the query is posted to th
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/search-list.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/search-list.png" height="700" /></b></td>
|
||||||
<td><b style="font-size:30px"><img src="images/search-preview.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/search-preview.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Search (list)</td>
|
<td>Search (list)</td>
|
||||||
@@ -71,7 +69,7 @@ Creators are able to configure their profile using NeoPass.
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/channel.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/channel.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Channel</td>
|
<td>Channel</td>
|
||||||
@@ -112,7 +110,7 @@ The app offers a lot of settings customizing how the app looks and feels. An exa
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/settings.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/settings.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Settings</td>
|
<td>Settings</td>
|
||||||
@@ -125,8 +123,8 @@ Playlists allow you to make a collection of videos that you can create and custo
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/playlists.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/playlists.png" height="700" /></b></td>
|
||||||
<td><b style="font-size:30px"><img src="images/playlist.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/playlist.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Playlists</td>
|
<td>Playlists</td>
|
||||||
@@ -142,7 +140,7 @@ Both individual videos and playlists can be downloaded for local, offline playba
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/downloads.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/downloads.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Downloads</td>
|
<td>Downloads</td>
|
||||||
@@ -157,7 +155,7 @@ For more information about casting please click [here](./docs/casting.md).
|
|||||||
|
|
||||||
<table border="0">
|
<table border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td><b style="font-size:30px"><img src="images/casting.jpg" height="700" /></b></td>
|
<td><b style="font-size:30px"><img src="images/casting.png" height="700" /></b></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Casting</td>
|
<td>Casting</td>
|
||||||
@@ -182,6 +180,12 @@ In the future we hope to offer users the choice of their desired recommendation
|
|||||||
|
|
||||||
1. Download a copy of the repository.
|
1. Download a copy of the repository.
|
||||||
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
|
2. Open the project in Android Studio: Once the repository is cloned, you can open it in Android Studio by selecting "Open an Existing Project" from the welcome screen and navigating to the directory where you cloned the repository.
|
||||||
|
3. Open the terminal in Android Studio by clicking on the terminal icon on bottom left and run the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
|
3. Build the project: With the project open in Android Studio, you can build it by selecting "Build > Make Project" from the main menu. This will compile the code and generate an APK file that you can install on your device or emulator.
|
||||||
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
|
4. Run the project: To run the project, select "Run > Run 'app'" from the main menu. This will launch the app on your device or emulator, allowing you to test it and make any necessary changes.
|
||||||
|
|
||||||
@@ -199,7 +203,6 @@ Create a tag on the master branch, incrementing the last version number by 1 (fo
|
|||||||
|
|
||||||
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
|
Click on the CI/CD tab, you should now see the tests and build are in progress. If the build succeeds the last step will become available. The last step is a manual action which can be triggered by clicking the run button on the action. This action will deploy the build to all users using the app through auto-update.
|
||||||
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).
|
The documentation can be found [here](https://gitlab.futo.org/videostreaming/documents/-/wikis/API-Overview).
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:22c06ca0d1a5808b2fc0a12227d5915b3126bc0b9b1305cf6bab855f2ec6fcbb
|
||||||
|
size 36133152
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:50b53a5d297d0c3008b3359a452a5c9e7e9f530533ec197e3dd1e9f08f9e84ad
|
||||||
|
size 6342128
|
||||||
+56
-44
@@ -1,8 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'org.jetbrains.kotlin.android'
|
id 'org.jetbrains.kotlin.android'
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21'
|
id 'org.jetbrains.kotlin.plugin.serialization' version '2.2.21'
|
||||||
id 'org.ajoberstar.grgit' version '1.7.2'
|
id 'org.ajoberstar.grgit' version '5.3.3'
|
||||||
id 'com.google.protobuf'
|
id 'com.google.protobuf'
|
||||||
id 'kotlin-parcelize'
|
id 'kotlin-parcelize'
|
||||||
id 'com.google.devtools.ksp'
|
id 'com.google.devtools.ksp'
|
||||||
@@ -39,7 +39,7 @@ protobuf {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace 'com.futo.platformplayer'
|
namespace 'com.futo.platformplayer'
|
||||||
compileSdk 34
|
compileSdk 36
|
||||||
flavorDimensions "buildType"
|
flavorDimensions "buildType"
|
||||||
productFlavors {
|
productFlavors {
|
||||||
stable {
|
stable {
|
||||||
@@ -97,7 +97,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk 28
|
minSdk 28
|
||||||
targetSdk 34
|
targetSdk 36
|
||||||
versionCode gitVersionCode
|
versionCode gitVersionCode
|
||||||
versionName gitVersionName
|
versionName gitVersionName
|
||||||
|
|
||||||
@@ -144,79 +144,91 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
buildConfig true
|
buildConfig true
|
||||||
}
|
}
|
||||||
|
sourceSets {
|
||||||
|
main {
|
||||||
|
jniLibs.srcDirs = ['src/main/jniLibs']
|
||||||
|
assets {
|
||||||
|
srcDirs 'src/main/assets', 'src/tests/assets', 'src/test/assets'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
//implementation 'com.google.dagger:dagger:2.48'
|
||||||
|
implementation 'androidx.test:monitor:1.8.0'
|
||||||
|
implementation 'com.google.android.material:material:1.13.0'
|
||||||
|
//annotationProcessor 'com.google.dagger:dagger-compiler:2.48'
|
||||||
|
|
||||||
//Core
|
//Core
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
implementation 'androidx.core:core-ktx:1.17.0'
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||||
implementation 'com.google.android.material:material:1.11.0'
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
implementation 'androidx.documentfile:documentfile:1.1.0'
|
||||||
|
|
||||||
//Images
|
//Images
|
||||||
annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
|
annotationProcessor 'com.github.bumptech.glide:compiler:5.0.5'
|
||||||
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
implementation 'com.github.bumptech.glide:glide:5.0.5'
|
||||||
|
|
||||||
//Async
|
//Async
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2"
|
||||||
|
|
||||||
//HTTP
|
//HTTP
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.11.0"
|
implementation "com.squareup.okhttp3:okhttp:5.3.0"
|
||||||
|
|
||||||
//JSON
|
//JSON
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2" //Used for structured json
|
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0" //Used for structured json
|
||||||
implementation 'com.google.code.gson:gson:2.10.1' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
implementation 'com.google.code.gson:gson:2.13.2' //Used for complex/anonymous cases like during development conversions (eg. V8RemoteObject)
|
||||||
|
|
||||||
//JS
|
//JS
|
||||||
implementation("com.caoccao.javet:javet-android:3.0.2")
|
implementation 'com.caoccao.javet:javet-v8-android:4.1.5'
|
||||||
|
|
||||||
//Exoplayer
|
//Exoplayer
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-dash:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-dash:1.9.0'
|
||||||
implementation 'androidx.media3:media3-ui:1.2.1'
|
implementation 'androidx.media3:media3-ui:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-hls:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-hls:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-rtsp:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-rtsp:1.9.0'
|
||||||
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.2.1'
|
implementation 'androidx.media3:media3-exoplayer-smoothstreaming:1.9.0'
|
||||||
implementation 'androidx.media3:media3-transformer:1.2.1'
|
implementation 'androidx.media3:media3-transformer:1.9.0'
|
||||||
implementation 'androidx.navigation:navigation-fragment-ktx:2.7.6'
|
implementation 'androidx.navigation:navigation-fragment-ktx:2.9.6'
|
||||||
implementation 'androidx.navigation:navigation-ui-ktx:2.7.6'
|
implementation 'androidx.navigation:navigation-ui-ktx:2.9.6'
|
||||||
implementation 'androidx.media:media:1.7.0'
|
implementation 'androidx.media:media:1.7.1'
|
||||||
|
|
||||||
//Other
|
//Other
|
||||||
implementation 'org.jmdns:jmdns:3.5.1'
|
implementation 'org.jsoup:jsoup:1.21.2'
|
||||||
implementation 'org.jsoup:jsoup:1.15.3'
|
|
||||||
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
implementation 'com.google.android.flexbox:flexbox:3.0.0'
|
||||||
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
|
||||||
implementation 'com.arthenica:ffmpeg-kit-full:5.1'
|
implementation fileTree(dir: 'aar', include: ['*.aar'])
|
||||||
implementation 'org.jetbrains.kotlin:kotlin-reflect:1.9.0'
|
implementation 'com.arthenica:smart-exception-java:0.2.1'
|
||||||
|
implementation 'org.jetbrains.kotlin:kotlin-reflect:2.2.0'
|
||||||
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
implementation 'com.github.dhaval2404:imagepicker:2.1'
|
||||||
implementation 'com.google.zxing:core:3.4.1'
|
implementation 'com.google.zxing:core:3.5.3'
|
||||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||||
|
implementation 'androidx.webkit:webkit:1.15.0'
|
||||||
|
|
||||||
//Protobuf
|
//Protobuf
|
||||||
implementation 'com.google.protobuf:protobuf-javalite:3.25.1'
|
implementation 'com.google.protobuf:protobuf-javalite:4.33.0'
|
||||||
|
|
||||||
implementation 'com.polycentric.core:app:1.0'
|
implementation 'com.polycentric.core:app:1.0'
|
||||||
implementation 'com.futo.futopay:app:1.0'
|
implementation 'com.futo.futopay:app:1.0'
|
||||||
implementation 'androidx.work:work-runtime-ktx:2.9.0'
|
implementation 'androidx.work:work-runtime-ktx:2.11.0'
|
||||||
implementation 'androidx.concurrent:concurrent-futures-ktx:1.1.0'
|
implementation 'androidx.concurrent:concurrent-futures-ktx:1.3.0'
|
||||||
|
|
||||||
//Database
|
//Database
|
||||||
implementation("androidx.room:room-runtime:2.6.1")
|
implementation("androidx.room:room-runtime:2.8.3")
|
||||||
annotationProcessor("androidx.room:room-compiler:2.6.1")
|
ksp("androidx.room:room-compiler:2.8.3")
|
||||||
ksp("androidx.room:room-compiler:2.6.1")
|
implementation("androidx.room:room-ktx:2.8.3")
|
||||||
implementation("androidx.room:room-ktx:2.6.1")
|
|
||||||
|
|
||||||
//Payment
|
//Payment
|
||||||
implementation 'com.stripe:stripe-android:20.35.1'
|
implementation 'com.stripe:stripe-android:22.0.0'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
|
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2'
|
||||||
testImplementation "org.jetbrains.kotlin:kotlin-test:1.8.22"
|
testImplementation "org.jetbrains.kotlin:kotlin-test:2.0.21"
|
||||||
testImplementation "org.xmlunit:xmlunit-core:2.9.1"
|
testImplementation "org.xmlunit:xmlunit-core:2.11.0"
|
||||||
testImplementation "org.mockito:mockito-core:5.4.0"
|
testImplementation "org.mockito:mockito-core:5.20.0"
|
||||||
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.7.0'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import toAndroidColor
|
||||||
|
|
||||||
|
class CSSColorTests {
|
||||||
|
@Test
|
||||||
|
fun test1() {
|
||||||
|
val androidHex = "#80336699"
|
||||||
|
val androidColorInt = Color.parseColor(androidHex)
|
||||||
|
|
||||||
|
val cssHex = "#33669980"
|
||||||
|
val cssColor = CSSColor.parseColor(cssHex)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||||
|
androidColorInt,
|
||||||
|
cssColor.toAndroidColor(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun test2() {
|
||||||
|
val androidHex = "#123ABC"
|
||||||
|
val androidColorInt = Color.parseColor(androidHex)
|
||||||
|
|
||||||
|
val cssHex = "#123ABCFF"
|
||||||
|
val cssColor = CSSColor.parseColor(cssHex)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"CSSColor($cssHex).toAndroidColor() should equal Color.parseColor($androidHex)",
|
||||||
|
androidColorInt,
|
||||||
|
cssColor.toAndroidColor()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package com.futo.platformplayer
|
|
||||||
|
|
||||||
import android.util.Base64
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.futo.platformplayer.casting.FCastCastingDevice
|
|
||||||
import com.futo.platformplayer.casting.Opcode
|
|
||||||
import com.futo.platformplayer.casting.models.FCastDecryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastEncryptedMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastKeyExchangeMessage
|
|
||||||
import com.futo.platformplayer.casting.models.FCastPlayMessage
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import java.security.KeyFactory
|
|
||||||
import java.security.spec.PKCS8EncodedKeySpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class FCastEncryptionTests {
|
|
||||||
@Test
|
|
||||||
fun testDHEncryptionSelf() {
|
|
||||||
val keyPair1 = FCastCastingDevice.generateKeyPair()
|
|
||||||
val keyPair2 = FCastCastingDevice.generateKeyPair()
|
|
||||||
Log.i("testDHEncryptionSelf", "privates (1: ${Base64.encodeToString(keyPair1.private.encoded, Base64.NO_WRAP)}, 2: ${Base64.encodeToString(keyPair2.private.encoded, Base64.NO_WRAP)})")
|
|
||||||
|
|
||||||
val keyExchangeMessage1 = FCastCastingDevice.getKeyExchangeMessage(keyPair1)
|
|
||||||
val keyExchangeMessage2 = FCastCastingDevice.getKeyExchangeMessage(keyPair2)
|
|
||||||
Log.i("testDHEncryptionSelf", "publics (1: ${keyExchangeMessage1.publicKey}, 2: ${keyExchangeMessage2.publicKey})")
|
|
||||||
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(keyPair1.private, keyExchangeMessage2)
|
|
||||||
val aesKey2 = FCastCastingDevice.computeSharedSecret(keyPair2.private, keyExchangeMessage1)
|
|
||||||
|
|
||||||
assertEquals(Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP), Base64.encodeToString(aesKey2.encoded, Base64.NO_WRAP))
|
|
||||||
Log.i("testDHEncryptionSelf", "aesKey ${Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP)}")
|
|
||||||
|
|
||||||
val message = FCastPlayMessage("text/html")
|
|
||||||
val serializedBody = Json.encodeToString(message)
|
|
||||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
|
||||||
Log.i("testDHEncryptionSelf", Json.encodeToString(encryptedMessage))
|
|
||||||
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
|
||||||
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals(serializedBody, decryptedMessage.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testAESKeyGeneration() {
|
|
||||||
val cases = listOf(
|
|
||||||
listOf(
|
|
||||||
//Public other
|
|
||||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgEnOS0oHteVA+3kND3u4yXe7GGRohy1LkR9Q5tL4c4ylC5n4iSwWSoIhcSIvUMWth6KAhPhu05sMcPY74rFMSS2AGTNCdT/5KilediipuUMdFVvjGqfNMNH1edzW5mquIw3iXKdfQmfY/qxLTI2wccyDj4hHFhLCZL3Y+shsm3KF",
|
|
||||||
//Private self
|
|
||||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAeo/ceIeH8Jt1ZRNKX5aTHkMi23GCV1LtcS2O6Tktn9k8DCv7gIoekysQUhMyWtR+MsZlq2mXjr1JFpAyxl89rqoEPU6QDsGe9q8R4O8eBZ2u+48mkUkGSh7xPGRQUBvmhH2yk4hIEA8aK4BcYi1OTsCZtmk7pQq+uaFkKovD/8M=",
|
|
||||||
//AES
|
|
||||||
"7dpl1/6KQTTooOrFf2VlUOSqgrFHi6IYxapX0IxFfwk="
|
|
||||||
),
|
|
||||||
listOf(
|
|
||||||
//Public other
|
|
||||||
"MIIBHzCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECA4GEAAKBgGvIlCP/S+xpAuNEHSn4cEDOL1esUf+uMuY2Kp5J10a7HGbwzNd+7eYsgEc4+adddgB7hJgTvjsGg7lXUhHQ7WbfbCGgt7dbkx8qkic6Rgq4f5eRYd1Cgidw4MhZt7mEIOKrHweqnV6B9rypbXjbqauc6nGgtwx+Gvl6iLpVATRK",
|
|
||||||
//Private self
|
|
||||||
"MIIBIQIBADCBlQYJKoZIhvcNAQMBMIGHAoGBAP//////////yQ/aoiFowjTExmKLgNwc0SkCTgiKZ8x0Agu+pjsTmyJRSgh5jjQE3e+VGbPNOkMbMCsKbfJfFDdP4TVtbVHCReSFtXZiXn7G9ExC6aY37WsL/1y29Aa37e44a/taiZ+lrp8kEXxLH+ZJKGZR7OZTgf//////////AgECBIGDAoGAMXmiIgWyutbaO+f4UiMAb09iVVSCI6Lb6xzNyD2MpUZyk4/JOT04Daj4JeCKFkF1Fq79yKhrnFlXCrF4WFX00xUOXb8BpUUUH35XG5ApvolQQLL6N0om8/MYP4FK/3PUxuZAJz45TUsI/v3u6UqJelVTNL83ltcFbZDIfEVftRA=",
|
|
||||||
//AES
|
|
||||||
"a2tUSxnXifKohfNocAQHkAlPffDv6ReihJ7OojBGt0Q="
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for (case in cases) {
|
|
||||||
val decodedPrivateKey1 = Base64.decode(case[1], Base64.NO_WRAP)
|
|
||||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, case[0])
|
|
||||||
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
|
||||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
|
||||||
assertEquals(case[2], Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDHEncryptionKnown() {
|
|
||||||
val decodedPrivateKey1 = Base64.decode("MIIDJwIBADCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egSCAQQCggEAECNvEczf0y6IoX/IwhrPeWZ5IxrHcpwjcdVAuyZQLLlOq0iqnYMFcSD8QjMF8NKObfZZCDQUJlzGzRsG0oXsWiWtmoRvUZ9tQK0j28hDylpbyP00Bt9NlMgeHXkAy54P7Z2v/BPCd3o23kzjgXzYaSRuCFY7zQo1g1IQG8mfjYjdE4jjRVdVrlh8FS8x4OLPeglc+cp2/kuyxaVEfXAG84z/M8019mRSfdczi4z1iidPX6HgDEEWsN42Ud60mNKy5jsQpQYkRdOLmxR3+iQEtGFjdzbVhVCUr7S5EORU9B1MOl5gyPJpjfU3baOqrg6WXVyTvMDaA05YEnAHQNOOfA==", Base64.NO_WRAP)
|
|
||||||
val keyExchangeMessage2 = FCastKeyExchangeMessage(1, "MIIDJTCCAhgGCSqGSIb3DQEDATCCAgkCggEBAJVHXPXZPllsP80dkCrdAvQn9fPHIQMTu0X7TVuy5f4cvWeM1LvdhMmDa+HzHAd3clrrbC/Di4X0gHb6drzYFGzImm+y9wbdcZiYwgg9yNiW+EBi4snJTRN7BUqNgJatuNUZUjmO7KhSoK8S34Pkdapl1OwMOKlWDVZhGG/5i5/J62Du6LAwN2sja8c746zb10/WHB0kdfowd7jwgEZ4gf9+HKVv7gZteVBq3lHtu1RDpWOSfbxLpSAIZ0YXXIiFkl68ZMYUeQZ3NJaZDLcU7GZzBOJh+u4zs8vfAI4MP6kGUNl9OQnJJ1v0rIb/yz0D5t/IraWTQkLdbTvMoqQGywsCggEAQt67naWz2IzJVuCHh+w/Ogm7pfSLiJp0qvUxdKoPvn48W4/NelO+9WOw6YVgMolgqVF/QBTTMl/Hlivx4Ek3DXbRMUp2E355Lz8NuFnQleSluTICTweezy7wnHl0UrB3DhNQeC7Vfd95SXnc7yPLlvGDBhllxOvJPJxxxWuSWVWnX5TMzxRJrEPVhtC+7kMlGwsihzSdaN4NFEQD8T6AL0FG2ILgV68ZtvYnXGZ2yPoOPKJxOjJX/Rsn0GOfaV40fY0c+ayBmibKmwTLDrm3sDWYjRW7rGUhKlUjnPx+WPrjjXJQq5mR/7yXE0Al/ozgTEOZrZZWm+kaVG9JeGk8egOCAQUAAoIBAGlL9EYsrFz3I83NdlwhM241M+M7PA9P5WXgtdvS+pcalIaqN2IYdfzzCUfye7lchVkT9A2Y9eWQYX0OUhmjf8PPKkRkATLXrqO5HTsxV96aYNxMjz5ipQ6CaErTQaPLr3OPoauIMPVVI9zM+WT0KOGp49YMyx+B5rafT066vOVbF/0z1crq0ZXxyYBUv135rwFkIHxBMj5bhRLXKsZ2G5aLAZg0DsVam104mgN/v75f7Spg/n5hO7qxbNgbvSrvQ7Ag/rMk5T3sk7KoM23Qsjl08IZKs2jjx21MiOtyLqGuCW6GOTNK4yEEDF5gA0K13eXGwL5lPS0ilRw+Lrw7cJU=")
|
|
||||||
|
|
||||||
val keyFactory = KeyFactory.getInstance("DH")
|
|
||||||
val privateKeySpec = PKCS8EncodedKeySpec(decodedPrivateKey1)
|
|
||||||
val privateKey = keyFactory.generatePrivate(privateKeySpec)
|
|
||||||
val aesKey1 = FCastCastingDevice.computeSharedSecret(privateKey, keyExchangeMessage2)
|
|
||||||
assertEquals("vI5LGE625zGEG350ggkyBsIAXm2y4sNohiPcED1oAEE=", Base64.encodeToString(aesKey1.encoded, Base64.NO_WRAP))
|
|
||||||
|
|
||||||
val message = FCastPlayMessage("text/html")
|
|
||||||
val serializedBody = Json.encodeToString(message)
|
|
||||||
val encryptedMessage = FCastCastingDevice.encryptMessage(aesKey1, FCastDecryptedMessage(Opcode.Play.value.toLong(), serializedBody))
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey1, encryptedMessage)
|
|
||||||
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals(serializedBody, decryptedMessage.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testDecryptMessageKnown() {
|
|
||||||
val encryptedMessage = Json.decodeFromString<FCastEncryptedMessage>("{\"version\":1,\"iv\":\"C4H70VC5FWrNtkty9/cLIA==\",\"blob\":\"K6/N7JMyi1PFwKhU0mFj7ZJmd/tPp3NCOMldmQUtDaQ7hSmPoIMI5QNMOj+NFEiP4qTgtYp5QmBPoQum6O88pA==\"}")
|
|
||||||
val aesKey = SecretKeySpec(Base64.decode("+hr9Jg8yre7S9WGUohv2AUSzHNQN514JPh6MoFAcFNU=", Base64.NO_WRAP), "AES")
|
|
||||||
val decryptedMessage = FCastCastingDevice.decryptMessage(aesKey, encryptedMessage)
|
|
||||||
assertEquals(Opcode.Play.value.toLong(), decryptedMessage.opcode)
|
|
||||||
assertEquals("{\"container\":\"text/html\"}", decryptedMessage.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.futo.platformplayer.noise.protocol.Noise
|
||||||
|
import com.futo.platformplayer.sync.internal.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.selects.select
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.net.Socket
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.random.Random
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
/*
|
||||||
|
class SyncServerTests {
|
||||||
|
|
||||||
|
//private val relayHost = "relay.grayjay.app"
|
||||||
|
//private val relayKey = "xGbHRzDOvE6plRbQaFgSen82eijF+gxS0yeUaeEErkw="
|
||||||
|
private val relayKey = "XlUaSpIlRaCg0TGzZ7JYmPupgUHDqTZXUUBco2K7ejw="
|
||||||
|
private val relayHost = "192.168.1.138"
|
||||||
|
private val relayPort = 9000
|
||||||
|
|
||||||
|
/** Creates a client connected to the live relay server. */
|
||||||
|
private suspend fun createClient(
|
||||||
|
onHandshakeComplete: ((SyncSocketSession) -> Unit)? = null,
|
||||||
|
onData: ((SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit)? = null,
|
||||||
|
onNewChannel: ((SyncSocketSession, ChannelRelayed) -> Unit)? = null,
|
||||||
|
isHandshakeAllowed: ((LinkType, SyncSocketSession, String, String?, UInt) -> Boolean)? = null,
|
||||||
|
onException: ((Throwable) -> Unit)? = null
|
||||||
|
): SyncSocketSession = withContext(Dispatchers.IO) {
|
||||||
|
val p = Noise.createDH("25519")
|
||||||
|
p.generateKeyPair()
|
||||||
|
val socket = Socket(relayHost, relayPort)
|
||||||
|
val inputStream = LittleEndianDataInputStream(socket.getInputStream())
|
||||||
|
val outputStream = LittleEndianDataOutputStream(socket.getOutputStream())
|
||||||
|
val tcs = CompletableDeferred<Boolean>()
|
||||||
|
val socketSession = SyncSocketSession(
|
||||||
|
relayHost,
|
||||||
|
p,
|
||||||
|
inputStream,
|
||||||
|
outputStream,
|
||||||
|
onClose = { socket.close() },
|
||||||
|
onHandshakeComplete = { s ->
|
||||||
|
onHandshakeComplete?.invoke(s)
|
||||||
|
tcs.complete(true)
|
||||||
|
},
|
||||||
|
onData = onData ?: { _, _, _, _ -> },
|
||||||
|
onNewChannel = onNewChannel ?: { _, _ -> },
|
||||||
|
isHandshakeAllowed = isHandshakeAllowed ?: { _, _, _, _, _ -> true }
|
||||||
|
)
|
||||||
|
socketSession.authorizable = AlwaysAuthorized()
|
||||||
|
try {
|
||||||
|
socketSession.startAsInitiator(relayKey)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
onException?.invoke(e)
|
||||||
|
}
|
||||||
|
withTimeout(5000.milliseconds) { tcs.await() }
|
||||||
|
return@withContext socketSession
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun multipleClientsHandshake_Success() = runBlocking {
|
||||||
|
val client1 = createClient()
|
||||||
|
val client2 = createClient()
|
||||||
|
assertNotNull(client1.remotePublicKey, "Client 1 handshake failed")
|
||||||
|
assertNotNull(client2.remotePublicKey, "Client 2 handshake failed")
|
||||||
|
client1.stop()
|
||||||
|
client2.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishAndRequestConnectionInfo_Authorized_Success() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val clientC = createClient()
|
||||||
|
clientA.publishConnectionInformation(arrayOf(clientB.localPublicKey), 12345, true, true, true, true)
|
||||||
|
delay(100.milliseconds)
|
||||||
|
val infoB = clientB.requestConnectionInfo(clientA.localPublicKey)
|
||||||
|
val infoC = clientC.requestConnectionInfo(clientA.localPublicKey)
|
||||||
|
assertNotNull("Client B should receive connection info", infoB)
|
||||||
|
assertEquals(12345.toUShort(), infoB!!.port)
|
||||||
|
assertNull("Client C should not receive connection info (unauthorized)", infoC)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
clientC.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relayedTransport_Bidirectional_Success() = runBlocking {
|
||||||
|
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||||
|
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||||
|
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||||
|
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||||
|
channelA.authorizable = AlwaysAuthorized()
|
||||||
|
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||||
|
channelB.authorizable = AlwaysAuthorized()
|
||||||
|
channelTask.await()
|
||||||
|
|
||||||
|
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||||
|
channelB.setDataHandler { _, _, o, so, d ->
|
||||||
|
val b = ByteArray(d.remaining())
|
||||||
|
d.get(b)
|
||||||
|
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||||
|
}
|
||||||
|
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(1, 2, 3)))
|
||||||
|
|
||||||
|
val tcsDataA = CompletableDeferred<ByteArray>()
|
||||||
|
channelA.setDataHandler { _, _, o, so, d ->
|
||||||
|
val b = ByteArray(d.remaining())
|
||||||
|
d.get(b)
|
||||||
|
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataA.complete(b)
|
||||||
|
}
|
||||||
|
channelB.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(byteArrayOf(4, 5, 6)))
|
||||||
|
|
||||||
|
val receivedB = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||||
|
val receivedA = withTimeout(5000.milliseconds) { tcsDataA.await() }
|
||||||
|
assertArrayEquals(byteArrayOf(1, 2, 3), receivedB)
|
||||||
|
assertArrayEquals(byteArrayOf(4, 5, 6), receivedA)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relayedTransport_MaximumMessageSize_Success() = runBlocking {
|
||||||
|
val MAX_DATA_PER_PACKET = SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE - 8 - 16 - 16
|
||||||
|
val maxSizeData = ByteArray(MAX_DATA_PER_PACKET).apply { Random.nextBytes(this) }
|
||||||
|
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||||
|
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||||
|
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||||
|
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||||
|
channelA.authorizable = AlwaysAuthorized()
|
||||||
|
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||||
|
channelB.authorizable = AlwaysAuthorized()
|
||||||
|
channelTask.await()
|
||||||
|
|
||||||
|
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||||
|
channelB.setDataHandler { _, _, o, so, d ->
|
||||||
|
val b = ByteArray(d.remaining())
|
||||||
|
d.get(b)
|
||||||
|
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||||
|
}
|
||||||
|
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxSizeData))
|
||||||
|
val receivedData = withTimeout(5000.milliseconds) { tcsDataB.await() }
|
||||||
|
assertArrayEquals(maxSizeData, receivedData)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishAndGetRecord_Success() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val clientC = createClient()
|
||||||
|
val data = byteArrayOf(1, 2, 3)
|
||||||
|
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "testKey", data)
|
||||||
|
val recordB = clientB.getRecord(clientA.localPublicKey, "testKey")
|
||||||
|
val recordC = clientC.getRecord(clientA.localPublicKey, "testKey")
|
||||||
|
assertTrue(success)
|
||||||
|
assertNotNull(recordB)
|
||||||
|
assertArrayEquals(data, recordB!!.first)
|
||||||
|
assertNull("Unauthorized client should not access record", recordC)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
clientC.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getNonExistentRecord_ReturnsNull() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val record = clientB.getRecord(clientA.localPublicKey, "nonExistentKey")
|
||||||
|
assertNull("Getting non-existent record should return null", record)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateRecord_TimestampUpdated() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val key = "updateKey"
|
||||||
|
val data1 = byteArrayOf(1)
|
||||||
|
val data2 = byteArrayOf(2)
|
||||||
|
clientA.publishRecords(listOf(clientB.localPublicKey), key, data1)
|
||||||
|
val record1 = clientB.getRecord(clientA.localPublicKey, key)
|
||||||
|
delay(1000.milliseconds)
|
||||||
|
clientA.publishRecords(listOf(clientB.localPublicKey), key, data2)
|
||||||
|
val record2 = clientB.getRecord(clientA.localPublicKey, key)
|
||||||
|
assertNotNull(record1)
|
||||||
|
assertNotNull(record2)
|
||||||
|
assertTrue(record2!!.second > record1!!.second)
|
||||||
|
assertArrayEquals(data2, record2.first)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRecord_Success() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val data = byteArrayOf(1, 2, 3)
|
||||||
|
clientA.publishRecords(listOf(clientB.localPublicKey), "toDelete", data)
|
||||||
|
val success = clientB.deleteRecords(clientA.localPublicKey, clientB.localPublicKey, listOf("toDelete"))
|
||||||
|
val record = clientB.getRecord(clientA.localPublicKey, "toDelete")
|
||||||
|
assertTrue(success)
|
||||||
|
assertNull(record)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listRecordKeys_Success() = runBlocking {
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val keys = arrayOf("key1", "key2", "key3")
|
||||||
|
keys.forEach { key ->
|
||||||
|
clientA.publishRecords(listOf(clientB.localPublicKey), key, byteArrayOf(1))
|
||||||
|
}
|
||||||
|
val listedKeys = clientB.listRecordKeys(clientA.localPublicKey, clientB.localPublicKey)
|
||||||
|
assertArrayEquals(keys, listedKeys.map { it.first }.toTypedArray())
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun singleLargeMessageViaRelayedChannel_Success() = runBlocking {
|
||||||
|
val largeData = ByteArray(100000).apply { Random.nextBytes(this) }
|
||||||
|
val tcsA = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||||
|
val clientA = createClient(onNewChannel = { _, c -> tcsA.complete(c) })
|
||||||
|
val clientB = createClient(onNewChannel = { _, c -> tcsB.complete(c) })
|
||||||
|
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey) }
|
||||||
|
val channelA = withTimeout(5000.milliseconds) { tcsA.await() }
|
||||||
|
channelA.authorizable = AlwaysAuthorized()
|
||||||
|
val channelB = withTimeout(5000.milliseconds) { tcsB.await() }
|
||||||
|
channelB.authorizable = AlwaysAuthorized()
|
||||||
|
channelTask.await()
|
||||||
|
|
||||||
|
val tcsDataB = CompletableDeferred<ByteArray>()
|
||||||
|
channelB.setDataHandler { _, _, o, so, d ->
|
||||||
|
val b = ByteArray(d.remaining())
|
||||||
|
d.get(b)
|
||||||
|
if (o == Opcode.DATA.value && so == 0u.toUByte()) tcsDataB.complete(b)
|
||||||
|
}
|
||||||
|
channelA.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||||
|
val receivedData = withTimeout(10000.milliseconds) { tcsDataB.await() }
|
||||||
|
assertArrayEquals(largeData, receivedData)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun publishAndGetLargeRecord_Success() = runBlocking {
|
||||||
|
val largeData = ByteArray(1000000).apply { Random.nextBytes(this) }
|
||||||
|
val clientA = createClient()
|
||||||
|
val clientB = createClient()
|
||||||
|
val success = clientA.publishRecords(listOf(clientB.localPublicKey), "largeRecord", largeData)
|
||||||
|
val record = clientB.getRecord(clientA.localPublicKey, "largeRecord")
|
||||||
|
assertTrue(success)
|
||||||
|
assertNotNull(record)
|
||||||
|
assertArrayEquals(largeData, record!!.first)
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relayedTransport_WithValidAppId_Success() = runBlocking {
|
||||||
|
// Arrange: Set up clients
|
||||||
|
val allowedAppId = 1234u
|
||||||
|
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||||
|
|
||||||
|
// Client B requires appId 1234
|
||||||
|
val clientB = createClient(
|
||||||
|
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||||
|
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId }
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientA = createClient()
|
||||||
|
|
||||||
|
// Act: Start relayed channel with valid appId
|
||||||
|
val channelTask = async { clientA.startRelayedChannel(clientB.localPublicKey, appId = allowedAppId) }
|
||||||
|
val channelB = withTimeout(5.seconds) { tcsB.await() }
|
||||||
|
withTimeout(5.seconds) { channelTask.await() }
|
||||||
|
|
||||||
|
// Assert: Channel is established
|
||||||
|
assertNotNull("Channel should be created on target with valid appId", channelB)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun relayedTransport_WithInvalidAppId_Fails() = runBlocking {
|
||||||
|
// Arrange: Set up clients
|
||||||
|
val allowedAppId = 1234u
|
||||||
|
val invalidAppId = 5678u
|
||||||
|
val tcsB = CompletableDeferred<ChannelRelayed>()
|
||||||
|
|
||||||
|
// Client B requires appId 1234
|
||||||
|
val clientB = createClient(
|
||||||
|
onNewChannel = { _, c -> tcsB.complete(c) },
|
||||||
|
isHandshakeAllowed = { linkType, _, _, _, appId -> linkType == LinkType.Relayed && appId == allowedAppId },
|
||||||
|
onException = { }
|
||||||
|
)
|
||||||
|
|
||||||
|
val clientA = createClient()
|
||||||
|
|
||||||
|
// Act & Assert: Attempt with invalid appId should fail
|
||||||
|
try {
|
||||||
|
withTimeout(5.seconds) {
|
||||||
|
clientA.startRelayedChannel(clientB.localPublicKey, appId = invalidAppId)
|
||||||
|
}
|
||||||
|
fail("Starting relayed channel with invalid appId should fail")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// Expected: The channel creation should time out or fail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure no channel was created on client B
|
||||||
|
val completedTask = select {
|
||||||
|
tcsB.onAwait { "channel" }
|
||||||
|
async { delay(1.seconds); "timeout" }.onAwait { "timeout" }
|
||||||
|
}
|
||||||
|
assertEquals("No channel should be created with invalid appId", "timeout", completedTask)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
clientA.stop()
|
||||||
|
clientB.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AlwaysAuthorized : IAuthorizable {
|
||||||
|
override val isAuthorized: Boolean get() = true
|
||||||
|
}*/
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.futo.platformplayer.noise.protocol.DHState
|
||||||
|
import com.futo.platformplayer.noise.protocol.Noise
|
||||||
|
import com.futo.platformplayer.sync.internal.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.PipedInputStream
|
||||||
|
import java.io.PipedOutputStream
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import kotlin.random.Random
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
/*
|
||||||
|
data class PipeStreams(
|
||||||
|
val initiatorInput: LittleEndianDataInputStream,
|
||||||
|
val initiatorOutput: LittleEndianDataOutputStream,
|
||||||
|
val responderInput: LittleEndianDataInputStream,
|
||||||
|
val responderOutput: LittleEndianDataOutputStream
|
||||||
|
)
|
||||||
|
|
||||||
|
typealias OnHandshakeComplete = (SyncSocketSession) -> Unit
|
||||||
|
typealias IsHandshakeAllowed = (LinkType, SyncSocketSession, String, String?, UInt) -> Boolean
|
||||||
|
typealias OnClose = (SyncSocketSession) -> Unit
|
||||||
|
typealias OnData = (SyncSocketSession, UByte, UByte, ByteBuffer) -> Unit
|
||||||
|
|
||||||
|
class SyncSocketTests {
|
||||||
|
private fun createPipeStreams(): PipeStreams {
|
||||||
|
val initiatorOutput = PipedOutputStream()
|
||||||
|
val responderOutput = PipedOutputStream()
|
||||||
|
val responderInput = PipedInputStream(initiatorOutput)
|
||||||
|
val initiatorInput = PipedInputStream(responderOutput)
|
||||||
|
return PipeStreams(
|
||||||
|
LittleEndianDataInputStream(initiatorInput), LittleEndianDataOutputStream(initiatorOutput),
|
||||||
|
LittleEndianDataInputStream(responderInput), LittleEndianDataOutputStream(responderOutput)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateKeyPair(): DHState {
|
||||||
|
val p = Noise.createDH("25519")
|
||||||
|
p.generateKeyPair()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSessions(
|
||||||
|
initiatorInput: LittleEndianDataInputStream,
|
||||||
|
initiatorOutput: LittleEndianDataOutputStream,
|
||||||
|
responderInput: LittleEndianDataInputStream,
|
||||||
|
responderOutput: LittleEndianDataOutputStream,
|
||||||
|
initiatorKeyPair: DHState,
|
||||||
|
responderKeyPair: DHState,
|
||||||
|
onInitiatorHandshakeComplete: OnHandshakeComplete,
|
||||||
|
onResponderHandshakeComplete: OnHandshakeComplete,
|
||||||
|
onInitiatorClose: OnClose? = null,
|
||||||
|
onResponderClose: OnClose? = null,
|
||||||
|
onClose: OnClose? = null,
|
||||||
|
isHandshakeAllowed: IsHandshakeAllowed? = null,
|
||||||
|
onDataA: OnData? = null,
|
||||||
|
onDataB: OnData? = null
|
||||||
|
): Pair<SyncSocketSession, SyncSocketSession> {
|
||||||
|
val initiatorSession = SyncSocketSession(
|
||||||
|
"", initiatorKeyPair, initiatorInput, initiatorOutput,
|
||||||
|
onClose = {
|
||||||
|
onClose?.invoke(it)
|
||||||
|
onInitiatorClose?.invoke(it)
|
||||||
|
},
|
||||||
|
onHandshakeComplete = onInitiatorHandshakeComplete,
|
||||||
|
onData = onDataA,
|
||||||
|
isHandshakeAllowed = isHandshakeAllowed
|
||||||
|
)
|
||||||
|
|
||||||
|
val responderSession = SyncSocketSession(
|
||||||
|
"", responderKeyPair, responderInput, responderOutput,
|
||||||
|
onClose = {
|
||||||
|
onClose?.invoke(it)
|
||||||
|
onResponderClose?.invoke(it)
|
||||||
|
},
|
||||||
|
onHandshakeComplete = onResponderHandshakeComplete,
|
||||||
|
onData = onDataB,
|
||||||
|
isHandshakeAllowed = isHandshakeAllowed
|
||||||
|
)
|
||||||
|
|
||||||
|
return Pair(initiatorSession, responderSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handshake_WithValidPairingCode_Succeeds(): Unit = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val validPairingCode = "secret"
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = validPairingCode)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(5.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handshake_WithInvalidPairingCode_Fails() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val validPairingCode = "secret"
|
||||||
|
val invalidPairingCode = "wrong"
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||||
|
val responderClosed = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onInitiatorClose = {
|
||||||
|
initiatorClosed.complete(true)
|
||||||
|
},
|
||||||
|
onResponderClose = {
|
||||||
|
responderClosed.complete(true)
|
||||||
|
},
|
||||||
|
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = invalidPairingCode)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(100.seconds) {
|
||||||
|
initiatorClosed.await()
|
||||||
|
responderClosed.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||||
|
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handshake_WithoutPairingCodeWhenRequired_Fails() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val validPairingCode = "secret"
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||||
|
val responderClosed = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onInitiatorClose = {
|
||||||
|
initiatorClosed.complete(true)
|
||||||
|
},
|
||||||
|
onResponderClose = {
|
||||||
|
responderClosed.complete(true)
|
||||||
|
},
|
||||||
|
isHandshakeAllowed = { _, _, _, pairingCode, _ -> pairingCode == validPairingCode }
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey) // No pairing code
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(5.seconds) {
|
||||||
|
initiatorClosed.await()
|
||||||
|
responderClosed.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||||
|
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun handshake_WithPairingCodeWhenNotRequired_Succeeds(): Unit = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val pairingCode = "unnecessary"
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
isHandshakeAllowed = { _, _, _, _, _ -> true } // Always allow
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey, pairingCode = pairingCode)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendAndReceive_SmallDataPacket_Succeeds() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onDataB = { _, opcode, subOpcode, data ->
|
||||||
|
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||||
|
val b = ByteArray(data.remaining())
|
||||||
|
data.get(b)
|
||||||
|
tcsDataReceived.complete(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure both sessions are authorized
|
||||||
|
initiatorSession.authorizable = Authorized()
|
||||||
|
responderSession.authorizable = Authorized()
|
||||||
|
|
||||||
|
val smallData = byteArrayOf(1, 2, 3)
|
||||||
|
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(smallData))
|
||||||
|
|
||||||
|
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||||
|
assertArrayEquals(smallData, receivedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sendAndReceive_ExactlyMaximumPacketSize_Succeeds() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onDataB = { _, opcode, subOpcode, data ->
|
||||||
|
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||||
|
val b = ByteArray(data.remaining())
|
||||||
|
data.get(b)
|
||||||
|
tcsDataReceived.complete(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure both sessions are authorized
|
||||||
|
initiatorSession.authorizable = Authorized()
|
||||||
|
responderSession.authorizable = Authorized()
|
||||||
|
|
||||||
|
val maxData = ByteArray(SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE).apply { Random.nextBytes(this) }
|
||||||
|
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(maxData))
|
||||||
|
|
||||||
|
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||||
|
assertArrayEquals(maxData, receivedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stream_LargeData_Succeeds() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onDataB = { _, opcode, subOpcode, data ->
|
||||||
|
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||||
|
val b = ByteArray(data.remaining())
|
||||||
|
data.get(b)
|
||||||
|
tcsDataReceived.complete(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure both sessions are authorized
|
||||||
|
initiatorSession.authorizable = Authorized()
|
||||||
|
responderSession.authorizable = Authorized()
|
||||||
|
|
||||||
|
val largeData = ByteArray(2 * (SyncSocketSession.MAXIMUM_PACKET_SIZE - SyncSocketSession.HEADER_SIZE)).apply { Random.nextBytes(this) }
|
||||||
|
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(largeData))
|
||||||
|
|
||||||
|
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||||
|
assertArrayEquals(largeData, receivedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun authorizedSession_CanSendData() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onDataB = { _, opcode, subOpcode, data ->
|
||||||
|
if (opcode == Opcode.DATA.value && subOpcode == 0u.toUByte()) {
|
||||||
|
val b = ByteArray(data.remaining())
|
||||||
|
data.get(b)
|
||||||
|
tcsDataReceived.complete(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize both sessions
|
||||||
|
initiatorSession.authorizable = Authorized()
|
||||||
|
responderSession.authorizable = Authorized()
|
||||||
|
|
||||||
|
val data = byteArrayOf(1, 2, 3)
|
||||||
|
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||||
|
|
||||||
|
val receivedData = withTimeout(10.seconds) { tcsDataReceived.await() }
|
||||||
|
assertArrayEquals(data, receivedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unauthorizedSession_CannotSendData() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val tcsDataReceived = CompletableDeferred<ByteArray>()
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onDataB = { _, _, _, _ -> }
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(10.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize initiator but not responder
|
||||||
|
initiatorSession.authorizable = Authorized()
|
||||||
|
responderSession.authorizable = Unauthorized()
|
||||||
|
|
||||||
|
val data = byteArrayOf(1, 2, 3)
|
||||||
|
initiatorSession.send(Opcode.DATA.value, 0u, ByteBuffer.wrap(data))
|
||||||
|
|
||||||
|
delay(1.seconds)
|
||||||
|
assertFalse(tcsDataReceived.isCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun directHandshake_WithValidAppId_Succeeds() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val allowedAppId = 1234u
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||||
|
linkType == LinkType.Direct && appId == allowedAppId
|
||||||
|
}
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = allowedAppId)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(5.seconds) {
|
||||||
|
handshakeInitiatorCompleted.await()
|
||||||
|
handshakeResponderCompleted.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNotNull(initiatorSession.remotePublicKey)
|
||||||
|
assertNotNull(responderSession.remotePublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun directHandshake_WithInvalidAppId_Fails() = runBlocking {
|
||||||
|
val (initiatorInput, initiatorOutput, responderInput, responderOutput) = createPipeStreams()
|
||||||
|
val initiatorKeyPair = generateKeyPair()
|
||||||
|
val responderKeyPair = generateKeyPair()
|
||||||
|
val allowedAppId = 1234u
|
||||||
|
val invalidAppId = 5678u
|
||||||
|
|
||||||
|
val handshakeInitiatorCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val handshakeResponderCompleted = CompletableDeferred<Boolean>()
|
||||||
|
val initiatorClosed = CompletableDeferred<Boolean>()
|
||||||
|
val responderClosed = CompletableDeferred<Boolean>()
|
||||||
|
|
||||||
|
val responderIsHandshakeAllowed = { linkType: LinkType, _: SyncSocketSession, _: String, _: String?, appId: UInt ->
|
||||||
|
linkType == LinkType.Direct && appId == allowedAppId
|
||||||
|
}
|
||||||
|
|
||||||
|
val (initiatorSession, responderSession) = createSessions(
|
||||||
|
initiatorInput, initiatorOutput, responderInput, responderOutput,
|
||||||
|
initiatorKeyPair, responderKeyPair,
|
||||||
|
{ handshakeInitiatorCompleted.complete(true) },
|
||||||
|
{ handshakeResponderCompleted.complete(true) },
|
||||||
|
onInitiatorClose = {
|
||||||
|
initiatorClosed.complete(true)
|
||||||
|
},
|
||||||
|
onResponderClose = {
|
||||||
|
responderClosed.complete(true)
|
||||||
|
},
|
||||||
|
isHandshakeAllowed = responderIsHandshakeAllowed
|
||||||
|
)
|
||||||
|
|
||||||
|
initiatorSession.startAsInitiator(responderSession.localPublicKey, appId = invalidAppId)
|
||||||
|
responderSession.startAsResponder()
|
||||||
|
|
||||||
|
withTimeout(5.seconds) {
|
||||||
|
initiatorClosed.await()
|
||||||
|
responderClosed.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(handshakeInitiatorCompleted.isCompleted)
|
||||||
|
assertFalse(handshakeResponderCompleted.isCompleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Authorized : IAuthorizable {
|
||||||
|
override val isAuthorized: Boolean = true
|
||||||
|
}
|
||||||
|
|
||||||
|
class Unauthorized : IAuthorizable {
|
||||||
|
override val isAuthorized: Boolean = false
|
||||||
|
}*/
|
||||||
@@ -11,10 +11,14 @@
|
|||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
||||||
|
<!--<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>-->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
<uses-permission android:name="android.permission.WRITE_SETTINGS" tools:ignore="ProtectedPermissions"/>
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -25,6 +29,8 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.FutoVideo"
|
android:theme="@style/Theme.FutoVideo"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:enableOnBackInvokedCallback"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
tools:targetApi="31"
|
tools:targetApi="31"
|
||||||
android:largeHeap="true">
|
android:largeHeap="true">
|
||||||
<provider
|
<provider
|
||||||
@@ -35,6 +41,12 @@
|
|||||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<receiver android:name=".receivers.MediaButtonReceiver" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<service android:name=".services.MediaPlaybackService"
|
<service android:name=".services.MediaPlaybackService"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
@@ -48,11 +60,11 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.MainActivity"
|
android:name=".activities.MainActivity"
|
||||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout"
|
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
android:theme="@style/Theme.FutoVideo.NoActionBar"
|
||||||
android:launchMode="singleTask"
|
android:windowSoftInputMode="adjustPan"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
android:supportsPictureInPicture="true">
|
android:supportsPictureInPicture="true">
|
||||||
|
|
||||||
@@ -145,36 +157,32 @@
|
|||||||
<data android:scheme="polycentric" />
|
<data android:scheme="polycentric" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.TestActivity"
|
android:name=".activities.TestActivity"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.SettingsActivity"
|
android:name=".activities.SettingsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.DeveloperActivity"
|
android:name=".activities.DeveloperActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ExceptionActivity"
|
android:name=".activities.ExceptionActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.CaptchaActivity"
|
android:name=".activities.CaptchaActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.LoginActivity"
|
android:name=".activities.LoginActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceActivity"
|
android:name=".activities.AddSourceActivity"
|
||||||
android:screenOrientation="portrait"
|
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar">
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
@@ -186,44 +194,79 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.AddSourceOptionsActivity"
|
android:name=".activities.AddSourceOptionsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricHomeActivity"
|
android:name=".activities.PolycentricHomeActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricBackupActivity"
|
android:name=".activities.PolycentricBackupActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricCreateProfileActivity"
|
android:name=".activities.PolycentricCreateProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricProfileActivity"
|
android:name=".activities.PolycentricProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricWhyActivity"
|
android:name=".activities.PolycentricWhyActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.PolycentricImportProfileActivity"
|
android:name=".activities.PolycentricImportProfileActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.ManageTabsActivity"
|
android:name=".activities.ManageTabsActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.QRCaptureActivity"
|
android:name=".activities.QRCaptureActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".activities.FCastGuideActivity"
|
android:name=".activities.FCastGuideActivity"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.SyncHomeActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.SyncPairActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.SyncShowPairingCodeActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
|
android:theme="@style/Theme.FutoVideo.NoActionBarFitsSystem" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.PolycentricModerationActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
<activity
|
||||||
|
android:name=".activities.QRCodeFullscreenActivity"
|
||||||
|
android:screenOrientation="sensorPortrait"
|
||||||
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
android:theme="@style/Theme.FutoVideo.NoActionBar" />
|
||||||
|
<service
|
||||||
|
android:name=".UpdateDownloadService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".UpdateActionReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".activities.InstallUpdateActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.App.TransparentNoUi"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:finishOnTaskLaunch="true" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1022,15 +1022,38 @@
|
|||||||
return x.value
|
return x.value
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
let settingsToUse = __DEV_SETTINGS ?? {};
|
||||||
|
if (true) {
|
||||||
|
const settings = this.Plugin?.currentPlugin?.settings;
|
||||||
|
if (settings) {
|
||||||
|
for (let setting of settings) {
|
||||||
|
if (typeof settingsToUse[setting.variable] == "undefined") {
|
||||||
|
switch (setting?.type?.toLowerCase()) {
|
||||||
|
case "boolean":
|
||||||
|
settingsToUse[setting.variable] = setting.default === 'true';
|
||||||
|
break;
|
||||||
|
case "dropdown":
|
||||||
|
let dropDownIndex = parseInt(setting.default);
|
||||||
|
if (dropDownIndex) {
|
||||||
|
settingsToUse[setting.variable] = setting.options[dropDownIndex];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(name == "enable") {
|
if(name == "enable") {
|
||||||
if(parameterVals.length > 0)
|
if(parameterVals.length > 0)
|
||||||
parameterVals[0] = this.Plugin.currentPlugin;
|
parameterVals[0] = this.Plugin.currentPlugin;
|
||||||
else
|
else
|
||||||
parameterVals.push(this.Plugin.currentPlugin);
|
parameterVals.push(this.Plugin.currentPlugin);
|
||||||
if(parameterVals.length > 1)
|
if(parameterVals.length > 1)
|
||||||
parameterVals[1] = __DEV_SETTINGS;
|
parameterVals[1] = settingsToUse;
|
||||||
else
|
else
|
||||||
parameterVals.push(__DEV_SETTINGS);
|
parameterVals.push(settingsToUse);
|
||||||
}
|
}
|
||||||
|
|
||||||
const func = source[name];
|
const func = source[name];
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,8 @@ let Type = {
|
|||||||
Streams: "STREAMS",
|
Streams: "STREAMS",
|
||||||
Mixed: "MIXED",
|
Mixed: "MIXED",
|
||||||
Live: "LIVE",
|
Live: "LIVE",
|
||||||
Subscriptions: "SUBSCRIPTIONS"
|
Subscriptions: "SUBSCRIPTIONS",
|
||||||
|
Shorts: "SHORTS"
|
||||||
},
|
},
|
||||||
Order: {
|
Order: {
|
||||||
Chronological: "CHRONOLOGICAL"
|
Chronological: "CHRONOLOGICAL"
|
||||||
@@ -31,7 +32,8 @@ let Type = {
|
|||||||
Text: {
|
Text: {
|
||||||
RAW: 0,
|
RAW: 0,
|
||||||
HTML: 1,
|
HTML: 1,
|
||||||
MARKUP: 2
|
MARKUP: 2,
|
||||||
|
CODE: 3
|
||||||
},
|
},
|
||||||
Chapter: {
|
Chapter: {
|
||||||
NORMAL: 0,
|
NORMAL: 0,
|
||||||
@@ -65,6 +67,7 @@ class ScriptException extends Error {
|
|||||||
super(arguments[0]);
|
super(arguments[0]);
|
||||||
this.plugin_type = "ScriptException";
|
this.plugin_type = "ScriptException";
|
||||||
this.message = arguments[0];
|
this.message = arguments[0];
|
||||||
|
this.msg = arguments[0];
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
super(msg);
|
super(msg);
|
||||||
@@ -101,6 +104,12 @@ class UnavailableException extends ScriptException {
|
|||||||
super("UnavailableException", msg);
|
super("UnavailableException", msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
class ReloadRequiredException extends ScriptException {
|
||||||
|
constructor(msg, reloadData) {
|
||||||
|
super("ReloadRequiredException", msg);
|
||||||
|
this.reloadData = reloadData;
|
||||||
|
}
|
||||||
|
}
|
||||||
class AgeException extends ScriptException {
|
class AgeException extends ScriptException {
|
||||||
constructor(msg) {
|
constructor(msg) {
|
||||||
super("AgeException", msg);
|
super("AgeException", msg);
|
||||||
@@ -201,7 +210,7 @@ class PlatformContent {
|
|||||||
obj = obj ?? {};
|
obj = obj ?? {};
|
||||||
this.id = obj.id ?? PlatformID(); //PlatformID
|
this.id = obj.id ?? PlatformID(); //PlatformID
|
||||||
this.name = obj.name ?? ""; //string
|
this.name = obj.name ?? ""; //string
|
||||||
this.thumbnails = obj.thumbnails; //Thumbnail[]
|
this.thumbnails = obj.thumbnails ?? new Thumbnails([]); //Thumbnail[]
|
||||||
this.author = obj.author; //PlatformAuthorLink
|
this.author = obj.author; //PlatformAuthorLink
|
||||||
this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long)
|
this.datetime = obj.datetime ?? obj.uploadDate ?? 0; //OffsetDateTime (Long)
|
||||||
this.url = obj.url ?? ""; //String
|
this.url = obj.url ?? ""; //String
|
||||||
@@ -243,7 +252,11 @@ class PlatformVideo extends PlatformContent {
|
|||||||
this.duration = obj.duration ?? -1; //Long
|
this.duration = obj.duration ?? -1; //Long
|
||||||
this.viewCount = obj.viewCount ?? -1; //Long
|
this.viewCount = obj.viewCount ?? -1; //Long
|
||||||
|
|
||||||
|
this.playbackTime = obj.playbackTime ?? -1;
|
||||||
|
this.playbackDate = obj.playbackDate ?? undefined;
|
||||||
|
|
||||||
this.isLive = obj.isLive ?? false; //Boolean
|
this.isLive = obj.isLive ?? false; //Boolean
|
||||||
|
this.isShort = !!obj.isShort ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class PlatformVideoDetails extends PlatformVideo {
|
class PlatformVideoDetails extends PlatformVideo {
|
||||||
@@ -260,6 +273,11 @@ class PlatformVideoDetails extends PlatformVideo {
|
|||||||
|
|
||||||
this.rating = obj.rating ?? null; //IRating
|
this.rating = obj.rating ?? null; //IRating
|
||||||
this.subtitles = obj.subtitles ?? [];
|
this.subtitles = obj.subtitles ?? [];
|
||||||
|
this.isShort = !!obj.isShort ?? false;
|
||||||
|
|
||||||
|
if (obj.getContentRecommendations) {
|
||||||
|
this.getContentRecommendations = obj.getContentRecommendations
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,12 +296,81 @@ class PlatformPostDetails extends PlatformPost {
|
|||||||
super(obj);
|
super(obj);
|
||||||
obj = obj ?? {};
|
obj = obj ?? {};
|
||||||
this.plugin_type = "PlatformPostDetails";
|
this.plugin_type = "PlatformPostDetails";
|
||||||
this.rating = obj.rating ?? RatingLikes(-1);
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
this.textType = obj.textType ?? 0;
|
this.textType = obj.textType ?? 0;
|
||||||
this.content = obj.content ?? "";
|
this.content = obj.content ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class PlatformWeb extends PlatformContent {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj, 7);
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "PlatformWeb";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class PlatformWebDetails extends PlatformWeb {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj, 7);
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "PlatformWebDetails";
|
||||||
|
this.html = obj.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformArticle extends PlatformContent {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj, 3);
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "PlatformArticle";
|
||||||
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
|
this.summary = obj.summary ?? "";
|
||||||
|
this.thumbnails = obj.thumbnails ?? new Thumbnails([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class PlatformArticleDetails extends PlatformArticle {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj, 3);
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "PlatformArticleDetails";
|
||||||
|
this.rating = obj.rating ?? new RatingLikes(-1);
|
||||||
|
this.segments = obj.segments ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleSegment {
|
||||||
|
constructor(type) {
|
||||||
|
this.type = type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleTextSegment extends ArticleSegment {
|
||||||
|
constructor(content, textType) {
|
||||||
|
super(1);
|
||||||
|
this.textType = textType;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleImagesSegment extends ArticleSegment {
|
||||||
|
constructor(images, caption) {
|
||||||
|
super(2);
|
||||||
|
this.images = images;
|
||||||
|
this.caption = caption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleHeaderSegment extends ArticleSegment {
|
||||||
|
constructor(content, level) {
|
||||||
|
super(3);
|
||||||
|
this.level = level;
|
||||||
|
this.content = content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ArticleNestedSegment extends ArticleSegment {
|
||||||
|
constructor(nested) {
|
||||||
|
super(9);
|
||||||
|
this.nested = nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
//Sources
|
//Sources
|
||||||
class VideoSourceDescriptor {
|
class VideoSourceDescriptor {
|
||||||
constructor(obj) {
|
constructor(obj) {
|
||||||
@@ -328,6 +415,18 @@ class VideoUrlSource {
|
|||||||
this.url = obj.url;
|
this.url = obj.url;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class VideoUrlWidevineSource extends VideoUrlSource {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj);
|
||||||
|
this.plugin_type = "VideoUrlWidevineSource";
|
||||||
|
|
||||||
|
this.licenseUri = obj.licenseUri;
|
||||||
|
if(obj.getLicenseRequestExecutor)
|
||||||
|
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class VideoUrlRangeSource extends VideoUrlSource {
|
class VideoUrlRangeSource extends VideoUrlSource {
|
||||||
@@ -362,8 +461,32 @@ class AudioUrlWidevineSource extends AudioUrlSource {
|
|||||||
super(obj);
|
super(obj);
|
||||||
this.plugin_type = "AudioUrlWidevineSource";
|
this.plugin_type = "AudioUrlWidevineSource";
|
||||||
|
|
||||||
this.bearerToken = obj.bearerToken;
|
|
||||||
this.licenseUri = obj.licenseUri;
|
this.licenseUri = obj.licenseUri;
|
||||||
|
if(obj.getLicenseRequestExecutor)
|
||||||
|
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||||
|
|
||||||
|
// deprecated api conversion
|
||||||
|
if(obj.bearerToken) {
|
||||||
|
this.getLicenseRequestExecutor = () => {
|
||||||
|
return {
|
||||||
|
executeRequest: (url, _headers, _method, license_request_data) => {
|
||||||
|
const response = http.POST(
|
||||||
|
url,
|
||||||
|
license_request_data,
|
||||||
|
{ Authorization: `Bearer ${obj.bearerToken}` },
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.body) {
|
||||||
|
throw new ScriptException("Unable to acquire license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class AudioUrlRangeSource extends AudioUrlSource {
|
class AudioUrlRangeSource extends AudioUrlSource {
|
||||||
@@ -391,6 +514,8 @@ class HLSSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class DashSource {
|
class DashSource {
|
||||||
@@ -404,8 +529,54 @@ class DashSource {
|
|||||||
this.language = obj.language;
|
this.language = obj.language;
|
||||||
if(obj.requestModifier)
|
if(obj.requestModifier)
|
||||||
this.requestModifier = obj.requestModifier;
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.language = obj?.language;
|
||||||
|
this.original = obj?.original;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
class DashWidevineSource extends DashSource {
|
||||||
|
constructor(obj) {
|
||||||
|
super(obj);
|
||||||
|
this.plugin_type = "DashWidevineSource";
|
||||||
|
|
||||||
|
this.licenseUri = obj.licenseUri;
|
||||||
|
if(obj.getLicenseRequestExecutor)
|
||||||
|
this.getLicenseRequestExecutor = obj.getLicenseRequestExecutor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class DashManifestRawSource {
|
||||||
|
constructor(obj) {
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "DashRawSource";
|
||||||
|
this.name = obj.name ?? "";
|
||||||
|
this.bitrate = obj.bitrate ?? 0;
|
||||||
|
this.container = obj.container ?? "";
|
||||||
|
this.codec = obj.codec ?? "";
|
||||||
|
this.duration = obj.duration ?? 0;
|
||||||
|
this.url = obj.url;
|
||||||
|
this.language = obj.language ?? Language.UNKNOWN;
|
||||||
|
if(obj.requestModifier)
|
||||||
|
this.requestModifier = obj.requestModifier;
|
||||||
|
this.original = obj?.original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashManifestRawAudioSource {
|
||||||
|
constructor(obj) {
|
||||||
|
obj = obj ?? {};
|
||||||
|
this.plugin_type = "DashRawAudioSource";
|
||||||
|
this.name = obj.name ?? "";
|
||||||
|
this.bitrate = obj.bitrate ?? 0;
|
||||||
|
this.container = obj.container ?? "";
|
||||||
|
this.codec = obj.codec ?? "";
|
||||||
|
this.duration = obj.duration ?? 0;
|
||||||
|
this.url = obj.url;
|
||||||
|
this.language = obj.language ?? Language.UNKNOWN;
|
||||||
|
this.manifest = obj.manifest ?? null;
|
||||||
|
if(obj.requestModifier)
|
||||||
|
this.requestModifier = obj.requestModifier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class RequestModifier {
|
class RequestModifier {
|
||||||
constructor(obj) {
|
constructor(obj) {
|
||||||
@@ -480,6 +651,8 @@ class PlatformComment {
|
|||||||
this.date = obj.date ?? 0;
|
this.date = obj.date ?? 0;
|
||||||
this.replyCount = obj.replyCount ?? 0;
|
this.replyCount = obj.replyCount ?? 0;
|
||||||
this.context = obj.context ?? {};
|
this.context = obj.context ?? {};
|
||||||
|
if(obj.getReplies)
|
||||||
|
this.getReplies = obj.getReplies;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,11 +724,12 @@ class LiveEventViewCount extends LiveEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
class LiveEventRaid extends LiveEvent {
|
class LiveEventRaid extends LiveEvent {
|
||||||
constructor(targetUrl, targetName, targetThumbnail) {
|
constructor(targetUrl, targetName, targetThumbnail, isOutgoing) {
|
||||||
super(100);
|
super(100);
|
||||||
this.targetUrl = targetUrl;
|
this.targetUrl = targetUrl;
|
||||||
this.targetName = targetName;
|
this.targetName = targetName;
|
||||||
this.targetThumbnail = targetThumbnail;
|
this.targetThumbnail = targetThumbnail;
|
||||||
|
this.isOutgoing = isOutgoing ?? true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,6 +802,7 @@ let plugin = {
|
|||||||
//To override by plugin
|
//To override by plugin
|
||||||
const source = {
|
const source = {
|
||||||
getHome() { return new ContentPager([], false, {}); },
|
getHome() { return new ContentPager([], false, {}); },
|
||||||
|
getShorts() { return new VideoPager([], false, {}); },
|
||||||
|
|
||||||
enable(config){ },
|
enable(config){ },
|
||||||
disable() {},
|
disable() {},
|
||||||
@@ -762,3 +937,99 @@ class URLSearchParams {
|
|||||||
return searchString;
|
return searchString;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var __REGEX_SPACE_CHARACTERS = /<%= spaceCharacters %>/g;
|
||||||
|
var __btoa_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
function btoa(input) {
|
||||||
|
input = String(input);
|
||||||
|
if (/[^\0-\xFF]/.test(input)) {
|
||||||
|
// Note: no need to special-case astral symbols here, as surrogates are
|
||||||
|
// matched, and the input is supposed to only contain ASCII anyway.
|
||||||
|
error(
|
||||||
|
'The string to be encoded contains characters outside of the ' +
|
||||||
|
'Latin1 range.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var padding = input.length % 3;
|
||||||
|
var output = '';
|
||||||
|
var position = -1;
|
||||||
|
var a;
|
||||||
|
var b;
|
||||||
|
var c;
|
||||||
|
var buffer;
|
||||||
|
// Make sure any padding is handled outside of the loop.
|
||||||
|
var length = input.length - padding;
|
||||||
|
|
||||||
|
while (++position < length) {
|
||||||
|
// Read three bytes, i.e. 24 bits.
|
||||||
|
a = input.charCodeAt(position) << 16;
|
||||||
|
b = input.charCodeAt(++position) << 8;
|
||||||
|
c = input.charCodeAt(++position);
|
||||||
|
buffer = a + b + c;
|
||||||
|
// Turn the 24 bits into four chunks of 6 bits each, and append the
|
||||||
|
// matching character for each of them to the output.
|
||||||
|
output += (
|
||||||
|
__btoa_TABLE.charAt(buffer >> 18 & 0x3F) +
|
||||||
|
__btoa_TABLE.charAt(buffer >> 12 & 0x3F) +
|
||||||
|
__btoa_TABLE.charAt(buffer >> 6 & 0x3F) +
|
||||||
|
__btoa_TABLE.charAt(buffer & 0x3F)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (padding == 2) {
|
||||||
|
a = input.charCodeAt(position) << 8;
|
||||||
|
b = input.charCodeAt(++position);
|
||||||
|
buffer = a + b;
|
||||||
|
output += (
|
||||||
|
__btoa_TABLE.charAt(buffer >> 10) +
|
||||||
|
__btoa_TABLE.charAt((buffer >> 4) & 0x3F) +
|
||||||
|
__btoa_TABLE.charAt((buffer << 2) & 0x3F) +
|
||||||
|
'='
|
||||||
|
);
|
||||||
|
} else if (padding == 1) {
|
||||||
|
buffer = input.charCodeAt(position);
|
||||||
|
output += (
|
||||||
|
__btoa_TABLE.charAt(buffer >> 2) +
|
||||||
|
__btoa_TABLE.charAt((buffer << 4) & 0x3F) +
|
||||||
|
'=='
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
function atob(input) {
|
||||||
|
input = String(input)
|
||||||
|
.replace(__REGEX_SPACE_CHARACTERS, '');
|
||||||
|
var length = input.length;
|
||||||
|
if (length % 4 == 0) {
|
||||||
|
input = input.replace(/==?$/, '');
|
||||||
|
length = input.length;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
length % 4 == 1 ||
|
||||||
|
// http://whatwg.org/C#alphanumeric-ascii-characters
|
||||||
|
/[^+a-zA-Z0-9/]/.test(input)
|
||||||
|
) {
|
||||||
|
error(
|
||||||
|
'Invalid character: the string to be decoded is not correctly encoded.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var bitCounter = 0;
|
||||||
|
var bitStorage;
|
||||||
|
var buffer;
|
||||||
|
var output = '';
|
||||||
|
var position = -1;
|
||||||
|
while (++position < length) {
|
||||||
|
buffer = __btoa_TABLE.indexOf(input.charAt(position));
|
||||||
|
bitStorage = bitCounter % 4 ? bitStorage * 64 + buffer : buffer;
|
||||||
|
// Unless this is the first of a group of 4 characters…
|
||||||
|
if (bitCounter++ % 4) {
|
||||||
|
// …convert the first 8 bits to a single ASCII character.
|
||||||
|
output += String.fromCharCode(
|
||||||
|
0xFF & bitStorage >> (-2 * bitCounter & 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object AppCaUpdater {
|
||||||
|
private const val CA_URL = "https://curl.se/ca/cacert.pem"
|
||||||
|
private const val CACHE_FILENAME = "curl-ca-bundle.pem"
|
||||||
|
private const val MAX_AGE_DAYS = 30
|
||||||
|
|
||||||
|
suspend fun ensureCaBundle(context: Context): File = withContext(Dispatchers.IO) {
|
||||||
|
val file = File(context.noBackupFilesDir, CACHE_FILENAME)
|
||||||
|
val needsUpdate = !file.exists() || isOlderThanDays(file, MAX_AGE_DAYS)
|
||||||
|
if (needsUpdate) {
|
||||||
|
downloadToFile(CA_URL, file)
|
||||||
|
}
|
||||||
|
return@withContext file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOlderThanDays(file: File, days: Int): Boolean {
|
||||||
|
val ageMs = System.currentTimeMillis() - file.lastModified()
|
||||||
|
return ageMs > days * 24L * 60L * 60L * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadToFile(urlStr: String, dest: File) {
|
||||||
|
val conn = (URL(urlStr).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15000
|
||||||
|
readTimeout = 15000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
dest.parentFile?.mkdirs()
|
||||||
|
dest.outputStream().use { output ->
|
||||||
|
input.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class CSSColor(r: Float, g: Float, b: Float, a: Float = 1f) {
|
||||||
|
init {
|
||||||
|
require(r in 0f..1f && g in 0f..1f && b in 0f..1f && a in 0f..1f) {
|
||||||
|
"RGBA channels must be in [0,1]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- RGB(A) channels stored 0–1 --
|
||||||
|
var r: Float = r.coerceIn(0f, 1f)
|
||||||
|
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||||
|
var g: Float = g.coerceIn(0f, 1f)
|
||||||
|
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||||
|
var b: Float = b.coerceIn(0f, 1f)
|
||||||
|
set(v) { field = v.coerceIn(0f, 1f); _hslDirty = true }
|
||||||
|
var a: Float = a.coerceIn(0f, 1f)
|
||||||
|
set(v) { field = v.coerceIn(0f, 1f) }
|
||||||
|
|
||||||
|
// -- Int views of RGBA 0–255 --
|
||||||
|
var red: Int
|
||||||
|
get() = (r * 255).roundToInt()
|
||||||
|
set(v) { r = (v.coerceIn(0, 255) / 255f) }
|
||||||
|
var green: Int
|
||||||
|
get() = (g * 255).roundToInt()
|
||||||
|
set(v) { g = (v.coerceIn(0, 255) / 255f) }
|
||||||
|
var blue: Int
|
||||||
|
get() = (b * 255).roundToInt()
|
||||||
|
set(v) { b = (v.coerceIn(0, 255) / 255f) }
|
||||||
|
var alpha: Int
|
||||||
|
get() = (a * 255).roundToInt()
|
||||||
|
set(v) { a = (v.coerceIn(0, 255) / 255f) }
|
||||||
|
|
||||||
|
// -- HSLA storage & lazy recompute flags --
|
||||||
|
private var _h: Float = 0f
|
||||||
|
private var _s: Float = 0f
|
||||||
|
private var _l: Float = 0f
|
||||||
|
private var _hslDirty = true
|
||||||
|
|
||||||
|
/** Hue [0...360) */
|
||||||
|
var hue: Float
|
||||||
|
get() { computeHslIfNeeded(); return _h }
|
||||||
|
set(v) { setHsl(v, saturation, lightness) }
|
||||||
|
|
||||||
|
/** Saturation [0...1] */
|
||||||
|
var saturation: Float
|
||||||
|
get() { computeHslIfNeeded(); return _s }
|
||||||
|
set(v) { setHsl(hue, v, lightness) }
|
||||||
|
|
||||||
|
/** Lightness [0...1] */
|
||||||
|
var lightness: Float
|
||||||
|
get() { computeHslIfNeeded(); return _l }
|
||||||
|
set(v) { setHsl(hue, saturation, v) }
|
||||||
|
|
||||||
|
private fun computeHslIfNeeded() {
|
||||||
|
if (!_hslDirty) return
|
||||||
|
val max = max(max(r, g), b)
|
||||||
|
val min = min(min(r, g), b)
|
||||||
|
val d = max - min
|
||||||
|
_l = (max + min) / 2f
|
||||||
|
_s = if (d == 0f) 0f else d / (1f - abs(2f * _l - 1f))
|
||||||
|
_h = when {
|
||||||
|
d == 0f -> 0f
|
||||||
|
max == r -> ((g - b) / d % 6f) * 60f
|
||||||
|
max == g -> (((b - r) / d) + 2f) * 60f
|
||||||
|
else -> (((r - g) / d) + 4f) * 60f
|
||||||
|
}.let { if (it < 0f) it + 360f else it }
|
||||||
|
_hslDirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all three HSL channels at once.
|
||||||
|
* Hue in degrees [0...360), s/l [0...1].
|
||||||
|
*/
|
||||||
|
fun setHsl(h: Float, s: Float, l: Float) {
|
||||||
|
val hh = ((h % 360f) + 360f) % 360f
|
||||||
|
val cc = (1f - abs(2f * l - 1f)) * s
|
||||||
|
val x = cc * (1f - abs((hh / 60f) % 2f - 1f))
|
||||||
|
val m = l - cc / 2f
|
||||||
|
|
||||||
|
val (rp, gp, bp) = when {
|
||||||
|
hh < 60f -> Triple(cc, x, 0f)
|
||||||
|
hh < 120f -> Triple(x, cc, 0f)
|
||||||
|
hh < 180f -> Triple(0f, cc, x)
|
||||||
|
hh < 240f -> Triple(0f, x, cc)
|
||||||
|
hh < 300f -> Triple(x, 0f, cc)
|
||||||
|
else -> Triple(cc, 0f, x)
|
||||||
|
}
|
||||||
|
|
||||||
|
r = rp + m; g = gp + m; b = bp + m
|
||||||
|
_h = hh; _s = s; _l = l; _hslDirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return 0xRRGGBBAA int */
|
||||||
|
fun toRgbaInt(): Int {
|
||||||
|
val ai = (a * 255).roundToInt() and 0xFF
|
||||||
|
val ri = (r * 255).roundToInt() and 0xFF
|
||||||
|
val gi = (g * 255).roundToInt() and 0xFF
|
||||||
|
val bi = (b * 255).roundToInt() and 0xFF
|
||||||
|
return (ri shl 24) or (gi shl 16) or (bi shl 8) or ai
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return 0xAARRGGBB int */
|
||||||
|
fun toArgbInt(): Int {
|
||||||
|
val ai = (a * 255).roundToInt() and 0xFF
|
||||||
|
val ri = (r * 255).roundToInt() and 0xFF
|
||||||
|
val gi = (g * 255).roundToInt() and 0xFF
|
||||||
|
val bi = (b * 255).roundToInt() and 0xFF
|
||||||
|
return (ai shl 24) or (ri shl 16) or (gi shl 8) or bi
|
||||||
|
}
|
||||||
|
|
||||||
|
// — Convenience modifiers (chainable) —
|
||||||
|
|
||||||
|
/** Lighten by fraction [0...1] */
|
||||||
|
fun lighten(fraction: Float): CSSColor = apply {
|
||||||
|
lightness = (lightness + fraction).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Darken by fraction [0...1] */
|
||||||
|
fun darken(fraction: Float): CSSColor = apply {
|
||||||
|
lightness = (lightness - fraction).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Increase saturation by fraction [0...1] */
|
||||||
|
fun saturate(fraction: Float): CSSColor = apply {
|
||||||
|
saturation = (saturation + fraction).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrease saturation by fraction [0...1] */
|
||||||
|
fun desaturate(fraction: Float): CSSColor = apply {
|
||||||
|
saturation = (saturation - fraction).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rotate hue by degrees (can be negative) */
|
||||||
|
fun rotateHue(degrees: Float): CSSColor = apply {
|
||||||
|
hue = (hue + degrees) % 360f
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Create from Android 0xAARRGGBB */
|
||||||
|
@JvmStatic fun fromArgb(color: Int): CSSColor {
|
||||||
|
val a = ((color ushr 24) and 0xFF) / 255f
|
||||||
|
val r = ((color ushr 16) and 0xFF) / 255f
|
||||||
|
val g = ((color ushr 8) and 0xFF) / 255f
|
||||||
|
val b = ( color and 0xFF) / 255f
|
||||||
|
return CSSColor(r, g, b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create from Android 0xRRGGBBAA */
|
||||||
|
@JvmStatic fun fromRgba(color: Int): CSSColor {
|
||||||
|
val r = ((color ushr 24) and 0xFF) / 255f
|
||||||
|
val g = ((color ushr 16) and 0xFF) / 255f
|
||||||
|
val b = ((color ushr 8) and 0xFF) / 255f
|
||||||
|
val a = ( color and 0xFF) / 255f
|
||||||
|
return CSSColor(r, g, b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic fun fromAndroidColor(color: Int): CSSColor {
|
||||||
|
return fromArgb(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val NAMED_HEX = mapOf(
|
||||||
|
"aliceblue" to "F0F8FF", "antiquewhite" to "FAEBD7", "aqua" to "00FFFF",
|
||||||
|
"aquamarine" to "7FFFD4", "azure" to "F0FFFF", "beige" to "F5F5DC",
|
||||||
|
"bisque" to "FFE4C4", "black" to "000000", "blanchedalmond" to "FFEBCD",
|
||||||
|
"blue" to "0000FF", "blueviolet" to "8A2BE2", "brown" to "A52A2A",
|
||||||
|
"burlywood" to "DEB887", "cadetblue" to "5F9EA0", "chartreuse" to "7FFF00",
|
||||||
|
"chocolate" to "D2691E", "coral" to "FF7F50", "cornflowerblue" to "6495ED",
|
||||||
|
"cornsilk" to "FFF8DC", "crimson" to "DC143C", "cyan" to "00FFFF",
|
||||||
|
"darkblue" to "00008B", "darkcyan" to "008B8B", "darkgoldenrod" to "B8860B",
|
||||||
|
"darkgray" to "A9A9A9", "darkgreen" to "006400", "darkgrey" to "A9A9A9",
|
||||||
|
"darkkhaki" to "BDB76B", "darkmagenta" to "8B008B", "darkolivegreen" to "556B2F",
|
||||||
|
"darkorange" to "FF8C00", "darkorchid" to "9932CC", "darkred" to "8B0000",
|
||||||
|
"darksalmon" to "E9967A", "darkseagreen" to "8FBC8F", "darkslateblue" to "483D8B",
|
||||||
|
"darkslategray" to "2F4F4F", "darkslategrey" to "2F4F4F", "darkturquoise" to "00CED1",
|
||||||
|
"darkviolet" to "9400D3", "deeppink" to "FF1493", "deepskyblue" to "00BFFF",
|
||||||
|
"dimgray" to "696969", "dimgrey" to "696969", "dodgerblue" to "1E90FF",
|
||||||
|
"firebrick" to "B22222", "floralwhite" to "FFFAF0", "forestgreen" to "228B22",
|
||||||
|
"fuchsia" to "FF00FF", "gainsboro" to "DCDCDC", "ghostwhite" to "F8F8FF",
|
||||||
|
"gold" to "FFD700", "goldenrod" to "DAA520", "gray" to "808080",
|
||||||
|
"green" to "008000", "greenyellow" to "ADFF2F", "grey" to "808080",
|
||||||
|
"honeydew" to "F0FFF0", "hotpink" to "FF69B4", "indianred" to "CD5C5C",
|
||||||
|
"indigo" to "4B0082", "ivory" to "FFFFF0", "khaki" to "F0E68C",
|
||||||
|
"lavender" to "E6E6FA", "lavenderblush" to "FFF0F5", "lawngreen" to "7CFC00",
|
||||||
|
"lemonchiffon" to "FFFACD", "lightblue" to "ADD8E6", "lightcoral" to "F08080",
|
||||||
|
"lightcyan" to "E0FFFF", "lightgoldenrodyellow" to "FAFAD2", "lightgray" to "D3D3D3",
|
||||||
|
"lightgreen" to "90EE90", "lightgrey" to "D3D3D3", "lightpink" to "FFB6C1",
|
||||||
|
"lightsalmon" to "FFA07A", "lightseagreen" to "20B2AA", "lightskyblue" to "87CEFA",
|
||||||
|
"lightslategray" to "778899", "lightslategrey" to "778899", "lightsteelblue" to "B0C4DE",
|
||||||
|
"lightyellow" to "FFFFE0", "lime" to "00FF00", "limegreen" to "32CD32",
|
||||||
|
"linen" to "FAF0E6", "magenta" to "FF00FF", "maroon" to "800000",
|
||||||
|
"mediumaquamarine" to "66CDAA", "mediumblue" to "0000CD", "mediumorchid" to "BA55D3",
|
||||||
|
"mediumpurple" to "9370DB", "mediumseagreen" to "3CB371", "mediumslateblue" to "7B68EE",
|
||||||
|
"mediumspringgreen" to "00FA9A", "mediumturquoise" to "48D1CC", "mediumvioletred" to "C71585",
|
||||||
|
"midnightblue" to "191970", "mintcream" to "F5FFFA", "mistyrose" to "FFE4E1",
|
||||||
|
"moccasin" to "FFE4B5", "navajowhite" to "FFDEAD", "navy" to "000080",
|
||||||
|
"oldlace" to "FDF5E6", "olive" to "808000", "olivedrab" to "6B8E23",
|
||||||
|
"orange" to "FFA500", "orangered" to "FF4500", "orchid" to "DA70D6",
|
||||||
|
"palegoldenrod" to "EEE8AA", "palegreen" to "98FB98", "paleturquoise" to "AFEEEE",
|
||||||
|
"palevioletred" to "DB7093", "papayawhip" to "FFEFD5", "peachpuff" to "FFDAB9",
|
||||||
|
"peru" to "CD853F", "pink" to "FFC0CB", "plum" to "DDA0DD",
|
||||||
|
"powderblue" to "B0E0E6", "purple" to "800080", "rebeccapurple" to "663399",
|
||||||
|
"red" to "FF0000", "rosybrown" to "BC8F8F", "royalblue" to "4169E1",
|
||||||
|
"saddlebrown" to "8B4513", "salmon" to "FA8072", "sandybrown" to "F4A460",
|
||||||
|
"seagreen" to "2E8B57", "seashell" to "FFF5EE", "sienna" to "A0522D",
|
||||||
|
"silver" to "C0C0C0", "skyblue" to "87CEEB", "slateblue" to "6A5ACD",
|
||||||
|
"slategray" to "708090", "slategrey" to "708090", "snow" to "FFFAFA",
|
||||||
|
"springgreen" to "00FF7F", "steelblue" to "4682B4", "tan" to "D2B48C",
|
||||||
|
"teal" to "008080", "thistle" to "D8BFD8", "tomato" to "FF6347",
|
||||||
|
"turquoise" to "40E0D0", "violet" to "EE82EE", "wheat" to "F5DEB3",
|
||||||
|
"white" to "FFFFFF", "whitesmoke" to "F5F5F5", "yellow" to "FFFF00",
|
||||||
|
"yellowgreen" to "9ACD32"
|
||||||
|
)
|
||||||
|
private val NAMED: Map<String, Int> = NAMED_HEX
|
||||||
|
.mapValues { (_, hexRgb) ->
|
||||||
|
// parse hexRgb ("RRGGBB") to Int, then OR in 0xFF000000 for full opacity
|
||||||
|
val rgb = hexRgb.toInt(16)
|
||||||
|
(rgb shl 8) or 0xFF
|
||||||
|
} + ("transparent" to 0x00000000)
|
||||||
|
|
||||||
|
private val HEX_REGEX = Regex("^#([0-9a-fA-F]{3,8})$", RegexOption.IGNORE_CASE)
|
||||||
|
private val RGB_REGEX = Regex("^rgba?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||||
|
private val HSL_REGEX = Regex("^hsla?\\(([^)]+)\\)\$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun parseColor(s: String): CSSColor {
|
||||||
|
val str = s.trim()
|
||||||
|
// named
|
||||||
|
NAMED[str.lowercase()]?.let { return it.RGBAtoCSSColor() }
|
||||||
|
|
||||||
|
// hex
|
||||||
|
HEX_REGEX.matchEntire(str)?.groupValues?.get(1)?.let { part ->
|
||||||
|
return parseHexPart(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rgb/rgba
|
||||||
|
RGB_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||||
|
return parseRgbParts(it.split(',').map(String::trim))
|
||||||
|
}
|
||||||
|
|
||||||
|
// hsl/hsla
|
||||||
|
HSL_REGEX.matchEntire(str)?.groupValues?.get(1)?.let {
|
||||||
|
return parseHslParts(it.split(',').map(String::trim))
|
||||||
|
}
|
||||||
|
|
||||||
|
error("Cannot parse color: \"$s\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHexPart(p: String): CSSColor {
|
||||||
|
// expand shorthand like "RGB" or "RGBA" to full 8-chars "RRGGBBAA"
|
||||||
|
val hex = when (p.length) {
|
||||||
|
3 -> p.map { "$it$it" }.joinToString("") + "FF"
|
||||||
|
4 -> p.map { "$it$it" }.joinToString("")
|
||||||
|
6 -> p + "FF"
|
||||||
|
8 -> p
|
||||||
|
else -> error("Invalid hex color: #$p")
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = hex.toLong(16).toInt()
|
||||||
|
val alpha = (parsed and 0xFF) shl 24
|
||||||
|
val rgbOnly = (parsed ushr 8) and 0x00FFFFFF
|
||||||
|
val argb = alpha or rgbOnly
|
||||||
|
return fromArgb(argb)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRgbParts(parts: List<String>): CSSColor {
|
||||||
|
require(parts.size == 3 || parts.size == 4) { "rgb/rgba needs 3 or 4 parts" }
|
||||||
|
|
||||||
|
// r/g/b: "128" → 128/255, "50%" → 0.5
|
||||||
|
fun channel(ch: String): Float =
|
||||||
|
if (ch.endsWith("%")) ch.removeSuffix("%").toFloat() / 100f
|
||||||
|
else ch.toFloat().coerceIn(0f, 255f) / 255f
|
||||||
|
|
||||||
|
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||||
|
fun alpha(a: String): Float =
|
||||||
|
if (a.endsWith("%")) a.removeSuffix("%").toFloat() / 100f
|
||||||
|
else a.toFloat().coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val r = channel(parts[0])
|
||||||
|
val g = channel(parts[1])
|
||||||
|
val b = channel(parts[2])
|
||||||
|
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||||
|
|
||||||
|
return CSSColor(r, g, b, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseHslParts(parts: List<String>): CSSColor {
|
||||||
|
require(parts.size == 3 || parts.size == 4) { "hsl/hsla needs 3 or 4 parts" }
|
||||||
|
|
||||||
|
fun hueOf(h: String): Float = when {
|
||||||
|
h.endsWith("deg") -> h.removeSuffix("deg").toFloat()
|
||||||
|
h.endsWith("grad") -> h.removeSuffix("grad").toFloat() * 0.9f
|
||||||
|
h.endsWith("rad") -> h.removeSuffix("rad").toFloat() * (180f / PI.toFloat())
|
||||||
|
h.endsWith("turn") -> h.removeSuffix("turn").toFloat() * 360f
|
||||||
|
else -> h.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
// for s and l you only ever see percentages
|
||||||
|
fun pct(p: String): Float =
|
||||||
|
p.removeSuffix("%").toFloat().coerceIn(0f, 100f) / 100f
|
||||||
|
|
||||||
|
// alpha: "0.5" → 0.5, "50%" → 0.5
|
||||||
|
fun alpha(a: String): Float =
|
||||||
|
if (a.endsWith("%")) pct(a)
|
||||||
|
else a.toFloat().coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val h = hueOf(parts[0])
|
||||||
|
val s = pct(parts[1])
|
||||||
|
val l = pct(parts[2])
|
||||||
|
val a = if (parts.size == 4) alpha(parts[3]) else 1f
|
||||||
|
|
||||||
|
return CSSColor(0f, 0f, 0f, a).apply { setHsl(h, s, l) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Int.RGBAtoCSSColor(): CSSColor = CSSColor.fromRgba(this)
|
||||||
|
fun Int.ARGBtoCSSColor(): CSSColor = CSSColor.fromArgb(this)
|
||||||
|
fun CSSColor.toAndroidColor(): Int = toArgbInt()
|
||||||
@@ -18,7 +18,10 @@ fun IAudioSource.isDownloadable(): Boolean = VideoHelper.isDownloadable(this);
|
|||||||
@UnstableApi
|
@UnstableApi
|
||||||
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
fun JSSource.getHttpDataSourceFactory(): HttpDataSource.Factory {
|
||||||
val requestModifier = getRequestModifier();
|
val requestModifier = getRequestModifier();
|
||||||
return if (requestModifier != null) {
|
val requestExecutor = getRequestExecutor();
|
||||||
|
return if (requestExecutor != null) {
|
||||||
|
JSHttpDataSource.Factory().setRequestExecutor(requestExecutor);
|
||||||
|
} else if (requestModifier != null) {
|
||||||
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
JSHttpDataSource.Factory().setRequestModifier(requestModifier);
|
||||||
} else {
|
} else {
|
||||||
DefaultHttpDataSource.Factory();
|
DefaultHttpDataSource.Factory();
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ import java.io.ByteArrayOutputStream
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.net.Inet4Address
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
import java.net.InetAddress
|
import java.net.InetAddress
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.net.Socket
|
import java.net.Socket
|
||||||
@@ -215,8 +216,12 @@ private fun ByteArray.toInetAddress(): InetAddress {
|
|||||||
return InetAddress.getByAddress(this);
|
return InetAddress.getByAddress(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
fun getConnectedSocket(attemptAddresses: List<InetAddress>, port: Int, timeoutMs: Int = 10_000): Socket? {
|
||||||
val timeout = 2000
|
ensureNotMainThread()
|
||||||
|
|
||||||
|
val addresses = if(!Settings.instance.casting.allowIpv6) attemptAddresses.filterIsInstance<Inet4Address>() else attemptAddresses;
|
||||||
|
if(addresses.isEmpty())
|
||||||
|
throw IllegalStateException("No valid addresses found (ipv6: ${(if(Settings.instance.casting.allowIpv6) "enabled" else "disabled")})");
|
||||||
|
|
||||||
if (addresses.isEmpty()) {
|
if (addresses.isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -226,7 +231,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||||||
val socket = Socket()
|
val socket = Socket()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeout) }
|
return socket.apply { this.connect(InetSocketAddress(addresses[0], port), timeoutMs) }
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
Log.i("getConnectedSocket", "Failed to connect to: ${addresses[0]}", e)
|
||||||
socket.close()
|
socket.close()
|
||||||
@@ -235,8 +240,11 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val sortedAddresses: List<InetAddress> = addresses
|
||||||
|
.sortedBy { addr -> addressScore(addr) }
|
||||||
|
|
||||||
val sockets: ArrayList<Socket> = arrayListOf();
|
val sockets: ArrayList<Socket> = arrayListOf();
|
||||||
for (i in addresses.indices) {
|
for (i in sortedAddresses.indices) {
|
||||||
sockets.add(Socket());
|
sockets.add(Socket());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +252,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||||||
var connectedSocket: Socket? = null;
|
var connectedSocket: Socket? = null;
|
||||||
val threads: ArrayList<Thread> = arrayListOf();
|
val threads: ArrayList<Thread> = arrayListOf();
|
||||||
for (i in 0 until sockets.size) {
|
for (i in 0 until sockets.size) {
|
||||||
val address = addresses[i];
|
val address = sortedAddresses[i];
|
||||||
val socket = sockets[i];
|
val socket = sockets[i];
|
||||||
val thread = Thread {
|
val thread = Thread {
|
||||||
try {
|
try {
|
||||||
@@ -254,7 +262,7 @@ fun getConnectedSocket(addresses: List<InetAddress>, port: Int): Socket? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.connect(InetSocketAddress(address, port), timeout);
|
socket.connect(InetSocketAddress(address, port), timeoutMs);
|
||||||
|
|
||||||
synchronized(syncObject) {
|
synchronized(syncObject) {
|
||||||
if (connectedSocket == null) {
|
if (connectedSocket == null) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.states.AnnouncementType
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
import com.futo.platformplayer.states.StatePlatform
|
import com.futo.platformplayer.states.StatePlatform
|
||||||
import com.futo.polycentric.core.ProcessHandle
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.SystemState
|
import com.futo.polycentric.core.SystemState
|
||||||
|
import com.futo.polycentric.core.base64UrlToByteArray
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -40,33 +40,25 @@ fun Protocol.ImageBundle?.selectHighestResolutionImage(): Protocol.ImageManifest
|
|||||||
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
|
return imageManifestsList.filter { it.byteCount < maximumFileSize }.maxByOrNull { abs(it.width * it.height) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.getDataLinkFromUrl(): Protocol.URLInfoDataLink? {
|
||||||
|
val urlData = if (this.startsWith("polycentric://")) {
|
||||||
|
this.substring("polycentric://".length)
|
||||||
|
} else this;
|
||||||
|
|
||||||
|
val urlBytes = urlData.base64UrlToByteArray();
|
||||||
|
val urlInfo = Protocol.URLInfo.parseFrom(urlBytes);
|
||||||
|
if (urlInfo.urlType != 4L) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataLink = Protocol.URLInfoDataLink.parseFrom(urlInfo.body);
|
||||||
|
return dataLink
|
||||||
|
}
|
||||||
|
|
||||||
fun Protocol.Claim.resolveChannelUrl(): String? {
|
fun Protocol.Claim.resolveChannelUrl(): String? {
|
||||||
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
fun Protocol.Claim.resolveChannelUrls(): List<String> {
|
||||||
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
return StatePlatform.instance.resolveChannelUrlsByClaimTemplates(this.claimType.toInt(), this.claimFieldsList.associate { Pair(it.key.toInt(), it.value) })
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun ProcessHandle.fullyBackfillServersAnnounceExceptions() {
|
|
||||||
val systemState = SystemState.fromStorageTypeSystemState(Store.instance.getSystemState(system))
|
|
||||||
if (!systemState.servers.contains(PolycentricCache.SERVER)) {
|
|
||||||
Logger.w("Backfill", "Polycentric prod server not added, adding it.")
|
|
||||||
addServer(PolycentricCache.SERVER)
|
|
||||||
}
|
|
||||||
|
|
||||||
val exceptions = fullyBackfillServers()
|
|
||||||
for (pair in exceptions) {
|
|
||||||
val server = pair.key
|
|
||||||
val exception = pair.value
|
|
||||||
|
|
||||||
StateAnnouncement.instance.registerAnnouncement(
|
|
||||||
"backfill-failed",
|
|
||||||
"Backfill failed",
|
|
||||||
"Failed to backfill server $server. $exception",
|
|
||||||
AnnouncementType.SESSION_RECURRING
|
|
||||||
);
|
|
||||||
|
|
||||||
Logger.e("Backfill", "Failed to backfill server $server.", exception)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.net.URISyntaxException
|
import java.net.URISyntaxException
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
|
||||||
//Syntax sugaring
|
//Syntax sugaring
|
||||||
inline fun <reified T> Any.assume(): T?{
|
inline fun <reified T> Any.assume(): T?{
|
||||||
@@ -25,4 +31,42 @@ fun String?.yesNoToBoolean(): Boolean {
|
|||||||
|
|
||||||
fun Boolean?.toYesNo(): String {
|
fun Boolean?.toYesNo(): String {
|
||||||
return if (this == true) "YES" else "NO"
|
return if (this == true) "YES" else "NO"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun InetAddress?.toUrlAddress(): String {
|
||||||
|
return when (this) {
|
||||||
|
is Inet6Address -> {
|
||||||
|
val hostAddr = this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
|
val index = hostAddr.indexOf('%')
|
||||||
|
if (index != -1) {
|
||||||
|
val addrPart = hostAddr.substring(0, index)
|
||||||
|
val scopeId = hostAddr.substring(index + 1)
|
||||||
|
"[${addrPart}%25${scopeId}]" // %25 is URL-encoded '%'
|
||||||
|
} else {
|
||||||
|
"[$hostAddr]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Inet4Address -> {
|
||||||
|
this.hostAddress ?: throw Exception("Invalid address: hostAddress is null")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
throw Exception("Invalid address type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long?.sToOffsetDateTimeUTC(): OffsetDateTime {
|
||||||
|
if (this == null || this < 0)
|
||||||
|
return OffsetDateTime.MIN
|
||||||
|
if(this > 4070912400)
|
||||||
|
return OffsetDateTime.MAX;
|
||||||
|
return OffsetDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Long?.msToOffsetDateTimeUTC(): OffsetDateTime {
|
||||||
|
if (this == null || this < 0)
|
||||||
|
return OffsetDateTime.MIN
|
||||||
|
if(this > 4070912400)
|
||||||
|
return OffsetDateTime.MAX;
|
||||||
|
return OffsetDateTime.ofInstant(Instant.ofEpochMilli(this), ZoneOffset.UTC)
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,32 @@ package com.futo.platformplayer
|
|||||||
|
|
||||||
import com.caoccao.javet.values.V8Value
|
import com.caoccao.javet.values.V8Value
|
||||||
import com.caoccao.javet.values.primitive.*
|
import com.caoccao.javet.values.primitive.*
|
||||||
|
import com.caoccao.javet.values.reference.IV8ValuePromise
|
||||||
import com.caoccao.javet.values.reference.V8ValueArray
|
import com.caoccao.javet.values.reference.V8ValueArray
|
||||||
|
import com.caoccao.javet.values.reference.V8ValueError
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
|
import com.caoccao.javet.values.reference.V8ValuePromise
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.JSClient
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.engine.exceptions.ScriptExecutionException
|
||||||
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
import com.futo.platformplayer.engine.exceptions.ScriptImplementationException
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.selects.SelectClause0
|
||||||
|
import kotlinx.coroutines.selects.SelectClause1
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import kotlin.coroutines.AbstractCoroutineContextElement
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.reflect.jvm.internal.impl.load.kotlin.JvmType
|
||||||
|
|
||||||
|
|
||||||
//V8
|
//V8
|
||||||
@@ -24,6 +46,10 @@ fun <R> V8Value?.orDefault(default: R, handler: (V8Value)->R): R {
|
|||||||
return handler(this);
|
return handler(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun V8Value.getSourcePlugin(): V8Plugin? {
|
||||||
|
return V8Plugin.getPluginFromRuntime(this.v8Runtime);
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
|
inline fun <reified T> V8Value.expectOrThrow(config: IV8PluginConfig, contextName: String): T {
|
||||||
if(this !is T)
|
if(this !is T)
|
||||||
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
|
throw ScriptImplementationException(config, "Expected ${contextName} to be of type ${T::class.simpleName}, but found ${this::class.simpleName}");
|
||||||
@@ -89,7 +115,27 @@ inline fun <reified T> V8ValueArray.expectV8Variants(config: IV8PluginConfig, co
|
|||||||
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
|
.map { kv-> kv.second.orNull { it.expectV8Variant<T>(config, contextName + "[${kv.first}]", ) } as T };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline fun V8Plugin.ensureIsBusy() {
|
||||||
|
this.let {
|
||||||
|
if (!it.isThreadAlreadyBusy()) {
|
||||||
|
val stacktrace = Thread.currentThread().stackTrace;
|
||||||
|
val message = "V8 USE OUTSIDE BUSY: " + stacktrace.drop(3)?.firstOrNull().toString() +
|
||||||
|
", " + stacktrace.drop(4)?.firstOrNull().toString() +
|
||||||
|
", " + stacktrace.drop(5)?.firstOrNull()?.toString() +
|
||||||
|
", " + stacktrace.drop(6)?.firstOrNull()?.toString();
|
||||||
|
Logger.w("Extensions_V8", message);
|
||||||
|
throw IllegalStateException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inline fun V8Value.ensureIsBusy() {
|
||||||
|
this?.getSourcePlugin()?.let {
|
||||||
|
it.ensureIsBusy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextName: String): T {
|
||||||
|
ensureIsBusy();
|
||||||
return when(T::class) {
|
return when(T::class) {
|
||||||
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
String::class -> this.expectOrThrow<V8ValueString>(config, contextName).value as T;
|
||||||
Int::class -> {
|
Int::class -> {
|
||||||
@@ -138,12 +184,241 @@ inline fun <reified T> V8Value.expectV8Variant(config: IV8PluginConfig, contextN
|
|||||||
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
|
else -> throw NotImplementedError("Type ${T::class.simpleName} not implemented conversion");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun V8ArrayToStringList(obj: V8ValueArray): List<String> = obj.keys.map { obj.getString(it) };
|
fun V8ArrayToStringList(obj: V8ValueArray): List<String> {
|
||||||
|
obj.ensureIsBusy();
|
||||||
|
return obj.keys.map { obj.getString(it) };
|
||||||
|
}
|
||||||
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
fun V8ObjectToHashMap(obj: V8ValueObject?): HashMap<String, String> {
|
||||||
if(obj == null)
|
if(obj == null)
|
||||||
return hashMapOf();
|
return hashMapOf();
|
||||||
|
obj.ensureIsBusy();
|
||||||
val map = hashMapOf<String, String>();
|
val map = hashMapOf<String, String>();
|
||||||
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
for(prop in obj.ownPropertyNames.keys.map { obj.ownPropertyNames.get<V8Value>(it).toString() })
|
||||||
map.put(prop, obj.getString(prop));
|
map.put(prop, obj.getString(prop));
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun <T: V8Value> V8ValuePromise.toV8ValueBlocking(plugin: V8Plugin): T {
|
||||||
|
val latch = CountDownLatch(1);
|
||||||
|
var promiseResult: T? = null;
|
||||||
|
var promiseException: Throwable? = null;
|
||||||
|
plugin.busy {
|
||||||
|
this.register(object: IV8ValuePromise.IListener {
|
||||||
|
override fun onFulfilled(p0: V8Value?) {
|
||||||
|
plugin.busy {
|
||||||
|
if(p0 is V8ValueError)
|
||||||
|
promiseException = ScriptExecutionException(plugin.config, p0.message);
|
||||||
|
else {
|
||||||
|
if(p0 is V8ValueObject)
|
||||||
|
p0.setWeak();
|
||||||
|
promiseResult = p0 as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
override fun onRejected(p0: V8Value?) {
|
||||||
|
plugin.busy {
|
||||||
|
promiseException = p0?.toException(plugin.config);
|
||||||
|
}
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
override fun onCatch(p0: V8Value?) {
|
||||||
|
plugin.busy {
|
||||||
|
promiseException = p0?.toException(plugin.config);
|
||||||
|
}
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.registerPromise(this) {
|
||||||
|
promiseException = CancellationException("Cancelled by system");
|
||||||
|
latch.countDown();
|
||||||
|
}
|
||||||
|
//Logger.i("V8", "V8ValueBlocking started (Busy) [" + blockCount + "]" + Thread.currentThread().stackTrace.drop(3)?.firstOrNull()?.toString() + ", " + Thread.currentThread().stackTrace.drop(4)?.firstOrNull()?.toString()+ ", " + Thread.currentThread().stackTrace.drop(5)?.firstOrNull()?.toString());
|
||||||
|
|
||||||
|
val isPending = plugin.busy {
|
||||||
|
promise.isPending
|
||||||
|
};
|
||||||
|
if(!isPending) {
|
||||||
|
plugin.busy {
|
||||||
|
try {
|
||||||
|
Logger.i("V8", "V8Promise resolved synchronously");
|
||||||
|
if(promise.isFulfilled)
|
||||||
|
promiseResult = promise.getResult<T>();
|
||||||
|
else
|
||||||
|
promiseException = promise.getResult<V8Value>().toException(plugin.config);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
promiseException = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
plugin.unbusy {
|
||||||
|
latch.await();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(promiseException != null)
|
||||||
|
throw promiseException!!;
|
||||||
|
return promiseResult!!;
|
||||||
|
}
|
||||||
|
fun <T: V8Value> V8ValuePromise.toV8ValueAsync(plugin: V8Plugin): V8Deferred<T> {
|
||||||
|
val underlyingDef = CompletableDeferred<T>();
|
||||||
|
val def = if(this.has("estDuration"))
|
||||||
|
V8Deferred(underlyingDef,
|
||||||
|
this.getOrDefault(plugin.config, "estDuration", "toV8ValueAsync", -1) ?: -1);
|
||||||
|
else
|
||||||
|
V8Deferred<T>(underlyingDef);
|
||||||
|
|
||||||
|
if(def.estDuration > 0)
|
||||||
|
Logger.i("V8", "Promise with duration: [${def.estDuration}]");
|
||||||
|
|
||||||
|
val promise = this;
|
||||||
|
plugin.busy {
|
||||||
|
this.register(object: IV8ValuePromise.IListener {
|
||||||
|
override fun onFulfilled(p0: V8Value?) {
|
||||||
|
plugin.busy {
|
||||||
|
plugin.resolvePromise(promise);
|
||||||
|
underlyingDef.complete(p0 as T);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onRejected(p0: V8Value?) {
|
||||||
|
try {
|
||||||
|
plugin.busy {
|
||||||
|
plugin.resolvePromise(promise);
|
||||||
|
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onRejected promise not implemented..");
|
||||||
|
Logger.i("V8", "Promise rejected, setting exception");
|
||||||
|
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("V8", "Rejection handling failed?" , ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onCatch(p0: V8Value?) {
|
||||||
|
try {
|
||||||
|
plugin.busy {
|
||||||
|
plugin.resolvePromise(promise);
|
||||||
|
val exceptionFound = p0?.toException(plugin.config) ?: NotImplementedError("onCatch promise not implemented..");
|
||||||
|
underlyingDef.completeExceptionally(CancellationException(exceptionFound.message, exceptionFound));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {
|
||||||
|
Logger.e("V8", "Catching handling failed?" , ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
plugin.registerPromise(promise) {
|
||||||
|
if(def.isActive)
|
||||||
|
def.cancel("Cancelled by system");
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun V8Value.toException(config: IV8PluginConfig): Throwable {
|
||||||
|
ensureIsBusy();
|
||||||
|
val p0 = this;
|
||||||
|
if(p0 is V8ValueObject) {
|
||||||
|
return V8Plugin.getExceptionFromPlugin(config, p0, null, null, null, "P:");
|
||||||
|
/*
|
||||||
|
val pluginType = p0.getOrDefault(config, "plugin_type", "Promise Exception", "")?.let { if(!it.isNullOrBlank()) it + "" else "" }
|
||||||
|
val msg = p0.getOrDefault<String?>(config, "msg", "Promise Exception", null)
|
||||||
|
?: p0.getOrDefault(config, "message", "Promise Exception", "");
|
||||||
|
return Throwable("Promise Failed: " + pluginType + msg);
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
else if(p0 is V8ValueString)
|
||||||
|
return Throwable("Promise Failed:" + p0.value);
|
||||||
|
else
|
||||||
|
return NotImplementedError("onCatch promise not implemented..");
|
||||||
|
}
|
||||||
|
|
||||||
|
class V8Deferred<T>(val deferred: Deferred<T>, val estDuration: Int = -1): Deferred<T> by deferred {
|
||||||
|
|
||||||
|
fun <R> convert(conversion: (result: T)->R): V8Deferred<R>{
|
||||||
|
val newDef = CompletableDeferred<R>()
|
||||||
|
this.invokeOnCompletion {
|
||||||
|
if(it != null)
|
||||||
|
newDef.completeExceptionally(it);
|
||||||
|
else
|
||||||
|
newDef.complete(conversion(this@V8Deferred.getCompleted()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return V8Deferred<R>(newDef, estDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun <T, R> merge(scope: CoroutineScope, defs: List<V8Deferred<T>>, conversion: (result: List<T>)->R): V8Deferred<R> {
|
||||||
|
|
||||||
|
var amount = -1;
|
||||||
|
for(def in defs)
|
||||||
|
amount = Math.max(amount, def.estDuration);
|
||||||
|
|
||||||
|
val def = scope.async {
|
||||||
|
val results = defs.map { it.await() };
|
||||||
|
return@async conversion(results);
|
||||||
|
}
|
||||||
|
return V8Deferred(def, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun <T: V8Value> V8ValueObject.invokeV8(method: String, vararg obj: Any?): T {
|
||||||
|
ensureIsBusy();
|
||||||
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
|
if(result is V8ValuePromise) {
|
||||||
|
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
fun <T: V8Value> V8ValueObject.invokeV8Async(method: String, vararg obj: Any?): V8Deferred<T> {
|
||||||
|
ensureIsBusy();
|
||||||
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
|
if(result is V8ValuePromise) {
|
||||||
|
return result.toV8ValueAsync(this.getSourcePlugin()!!);
|
||||||
|
}
|
||||||
|
return V8Deferred(CompletableDeferred(result as T));
|
||||||
|
}
|
||||||
|
fun V8ValueObject.invokeV8Void(method: String, vararg obj: Any?): V8Value {
|
||||||
|
ensureIsBusy();
|
||||||
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
|
if(result is V8ValuePromise) {
|
||||||
|
return result.toV8ValueBlocking(this.getSourcePlugin()!!);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
fun V8ValueObject.invokeV8VoidAsync(method: String, vararg obj: Any?): V8Deferred<V8Value> {
|
||||||
|
ensureIsBusy();
|
||||||
|
var result = this.invoke<V8Value>(method, *obj);
|
||||||
|
if(result is V8ValuePromise) {
|
||||||
|
val result = result.toV8ValueAsync<V8Value>(this.getSourcePlugin()!!);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return V8Deferred(CompletableDeferred(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun <T> Deferred<T>.awaitCancelConverted(): T {
|
||||||
|
try {
|
||||||
|
return this.await();
|
||||||
|
}
|
||||||
|
catch(ex: CancellationException) {
|
||||||
|
if(ex.cause != null) {
|
||||||
|
throw ex.cause!!;
|
||||||
|
}
|
||||||
|
throw ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> IPager<T>.toList(): List<T> {
|
||||||
|
val list = this.getResults().toMutableList();
|
||||||
|
|
||||||
|
while(this.hasMorePages()) {
|
||||||
|
this.nextPage();
|
||||||
|
list.addAll(this.getResults());
|
||||||
|
}
|
||||||
|
|
||||||
|
return list.toList();
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
import com.google.common.primitives.Ints
|
||||||
|
import com.google.common.primitives.Longs
|
||||||
|
import java.io.DataInput
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.io.FilterInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
class LittleEndianDataInputStream
|
||||||
|
/**
|
||||||
|
* Creates a `LittleEndianDataInputStream` that wraps the given stream.
|
||||||
|
*
|
||||||
|
* @param in the stream to delegate to
|
||||||
|
*/
|
||||||
|
(`in`: InputStream?) : FilterInputStream(Preconditions.checkNotNull(`in`)), DataInput {
|
||||||
|
/** This method will throw an [UnsupportedOperationException]. */
|
||||||
|
override fun readLine(): String {
|
||||||
|
throw UnsupportedOperationException("readLine is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readFully(b: ByteArray) {
|
||||||
|
ByteStreams.readFully(this, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readFully(b: ByteArray, off: Int, len: Int) {
|
||||||
|
ByteStreams.readFully(this, b, off, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun skipBytes(n: Int): Int {
|
||||||
|
return `in`.skip(n.toLong()).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readUnsignedByte(): Int {
|
||||||
|
val b1 = `in`.read()
|
||||||
|
if (0 > b1) {
|
||||||
|
throw EOFException()
|
||||||
|
}
|
||||||
|
|
||||||
|
return b1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an unsigned `short` as specified by [DataInputStream.readUnsignedShort],
|
||||||
|
* except using little-endian byte order.
|
||||||
|
*
|
||||||
|
* @return the next two bytes of the input stream, interpreted as an unsigned 16-bit integer in
|
||||||
|
* little-endian byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readUnsignedShort(): Int {
|
||||||
|
val b1 = readAndCheckByte()
|
||||||
|
val b2 = readAndCheckByte()
|
||||||
|
|
||||||
|
return Ints.fromBytes(0.toByte(), 0.toByte(), b2, b1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads an integer as specified by [DataInputStream.readInt], except using little-endian
|
||||||
|
* byte order.
|
||||||
|
*
|
||||||
|
* @return the next four bytes of the input stream, interpreted as an `int` in little-endian
|
||||||
|
* byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readInt(): Int {
|
||||||
|
val b1 = readAndCheckByte()
|
||||||
|
val b2 = readAndCheckByte()
|
||||||
|
val b3 = readAndCheckByte()
|
||||||
|
val b4 = readAndCheckByte()
|
||||||
|
|
||||||
|
return Ints.fromBytes(b4, b3, b2, b1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a `long` as specified by [DataInputStream.readLong], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @return the next eight bytes of the input stream, interpreted as a `long` in
|
||||||
|
* little-endian byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readLong(): Long {
|
||||||
|
val b1 = readAndCheckByte()
|
||||||
|
val b2 = readAndCheckByte()
|
||||||
|
val b3 = readAndCheckByte()
|
||||||
|
val b4 = readAndCheckByte()
|
||||||
|
val b5 = readAndCheckByte()
|
||||||
|
val b6 = readAndCheckByte()
|
||||||
|
val b7 = readAndCheckByte()
|
||||||
|
val b8 = readAndCheckByte()
|
||||||
|
|
||||||
|
return Longs.fromBytes(b8, b7, b6, b5, b4, b3, b2, b1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a `float` as specified by [DataInputStream.readFloat], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @return the next four bytes of the input stream, interpreted as a `float` in
|
||||||
|
* little-endian byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readFloat(): Float {
|
||||||
|
return java.lang.Float.intBitsToFloat(readInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a `double` as specified by [DataInputStream.readDouble], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @return the next eight bytes of the input stream, interpreted as a `double` in
|
||||||
|
* little-endian byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readDouble(): Double {
|
||||||
|
return java.lang.Double.longBitsToDouble(readLong())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readUTF(): String {
|
||||||
|
return DataInputStream(`in`).readUTF()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a `short` as specified by [DataInputStream.readShort], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @return the next two bytes of the input stream, interpreted as a `short` in little-endian
|
||||||
|
* byte order.
|
||||||
|
* @throws IOException if an I/O error occurs.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readShort(): Short {
|
||||||
|
return readUnsignedShort().toShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a char as specified by [DataInputStream.readChar], except using little-endian
|
||||||
|
* byte order.
|
||||||
|
*
|
||||||
|
* @return the next two bytes of the input stream, interpreted as a `char` in little-endian
|
||||||
|
* byte order
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readChar(): Char {
|
||||||
|
return readUnsignedShort().toChar()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readByte(): Byte {
|
||||||
|
return readUnsignedByte().toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun readBoolean(): Boolean {
|
||||||
|
return readUnsignedByte() != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a byte from the input stream checking that the end of file (EOF) has not been
|
||||||
|
* encountered.
|
||||||
|
*
|
||||||
|
* @return byte read from input
|
||||||
|
* @throws IOException if an error is encountered while reading
|
||||||
|
* @throws EOFException if the end of file (EOF) is encountered.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class, EOFException::class)
|
||||||
|
private fun readAndCheckByte(): Byte {
|
||||||
|
val b1 = `in`.read()
|
||||||
|
|
||||||
|
if (-1 == b1) {
|
||||||
|
throw EOFException()
|
||||||
|
}
|
||||||
|
|
||||||
|
return b1.toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import com.google.common.base.Preconditions
|
||||||
|
import com.google.common.primitives.Longs
|
||||||
|
import java.io.*
|
||||||
|
|
||||||
|
class LittleEndianDataOutputStream
|
||||||
|
/**
|
||||||
|
* Creates a `LittleEndianDataOutputStream` that wraps the given stream.
|
||||||
|
*
|
||||||
|
* @param out the stream to delegate to
|
||||||
|
*/
|
||||||
|
(out: OutputStream?) : FilterOutputStream(DataOutputStream(Preconditions.checkNotNull(out))),
|
||||||
|
DataOutput {
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun write(b: ByteArray, off: Int, len: Int) {
|
||||||
|
// Override slow FilterOutputStream impl
|
||||||
|
out.write(b, off, len)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeBoolean(v: Boolean) {
|
||||||
|
(out as DataOutputStream).writeBoolean(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeByte(v: Int) {
|
||||||
|
(out as DataOutputStream).writeByte(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated(
|
||||||
|
"""The semantics of {@code writeBytes(String s)} are considered dangerous. Please use
|
||||||
|
{@link #writeUTF(String s)}, {@link #writeChars(String s)} or another write method instead."""
|
||||||
|
)
|
||||||
|
@Throws(
|
||||||
|
IOException::class
|
||||||
|
)
|
||||||
|
override fun writeBytes(s: String) {
|
||||||
|
(out as DataOutputStream).writeBytes(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a char as specified by [DataOutputStream.writeChar], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeChar(v: Int) {
|
||||||
|
writeShort(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `String` as specified by [DataOutputStream.writeChars], except
|
||||||
|
* each character is written using little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeChars(s: String) {
|
||||||
|
for (i in 0 until s.length) {
|
||||||
|
writeChar(s[i].code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `double` as specified by [DataOutputStream.writeDouble], except
|
||||||
|
* using little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeDouble(v: Double) {
|
||||||
|
writeLong(java.lang.Double.doubleToLongBits(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `float` as specified by [DataOutputStream.writeFloat], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeFloat(v: Float) {
|
||||||
|
writeInt(java.lang.Float.floatToIntBits(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes an `int` as specified by [DataOutputStream.writeInt], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeInt(v: Int) {
|
||||||
|
val bytes = byteArrayOf(
|
||||||
|
(0xFF and v).toByte(),
|
||||||
|
(0xFF and (v shr 8)).toByte(),
|
||||||
|
(0xFF and (v shr 16)).toByte(),
|
||||||
|
(0xFF and (v shr 24)).toByte()
|
||||||
|
)
|
||||||
|
out.write(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `long` as specified by [DataOutputStream.writeLong], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeLong(v: Long) {
|
||||||
|
val bytes = Longs.toByteArray(java.lang.Long.reverseBytes(v))
|
||||||
|
write(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a `short` as specified by [DataOutputStream.writeShort], except using
|
||||||
|
* little-endian byte order.
|
||||||
|
*
|
||||||
|
* @throws IOException if an I/O error occurs
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeShort(v: Int) {
|
||||||
|
val bytes = byteArrayOf(
|
||||||
|
(0xFF and v).toByte(),
|
||||||
|
(0xFF and (v shr 8)).toByte()
|
||||||
|
)
|
||||||
|
out.write(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun writeUTF(str: String) {
|
||||||
|
(out as DataOutputStream).writeUTF(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overriding close() because FilterOutputStream's close() method pre-JDK8 has bad behavior:
|
||||||
|
// it silently ignores any exception thrown by flush(). Instead, just close the delegate stream.
|
||||||
|
// It should flush itself if necessary.
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
out.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.Window
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.core.graphics.Insets
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat.Type
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import androidx.core.view.doOnAttach
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import kotlin.math.max
|
||||||
|
|
||||||
|
class RootInsetsController private constructor(
|
||||||
|
private val activity: Activity,
|
||||||
|
private val window: Window,
|
||||||
|
private val root: ViewGroup
|
||||||
|
) {
|
||||||
|
private val controller by lazy { WindowInsetsControllerCompat(window, root) }
|
||||||
|
|
||||||
|
private val basePaddingLeft = root.paddingLeft
|
||||||
|
private val basePaddingTop = root.paddingTop
|
||||||
|
private val basePaddingRight = root.paddingRight
|
||||||
|
private val basePaddingBottom = root.paddingBottom
|
||||||
|
|
||||||
|
private var currentInsets: WindowInsetsCompat = WindowInsetsCompat.CONSUMED
|
||||||
|
private var fullscreen = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = Color.TRANSPARENT
|
||||||
|
controller.systemBarsBehavior =
|
||||||
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||||
|
currentInsets = insets
|
||||||
|
applyPadding()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
root.doOnAttach { ViewCompat.requestApplyInsets(root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun effectiveInsets(): Insets {
|
||||||
|
if (fullscreen) return Insets.NONE
|
||||||
|
|
||||||
|
val sys = currentInsets.getInsets(Type.systemBars())
|
||||||
|
val cut = currentInsets.getInsetsIgnoringVisibility(Type.displayCutout())
|
||||||
|
val portrait = activity.resources.configuration.orientation == android.content.res.Configuration.ORIENTATION_PORTRAIT
|
||||||
|
|
||||||
|
val top = if (portrait) max(sys.top, cut.top) else sys.top
|
||||||
|
return Insets.of(sys.left, top, sys.right, sys.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun applyPadding() {
|
||||||
|
val e = effectiveInsets()
|
||||||
|
root.updatePadding(
|
||||||
|
left = basePaddingLeft + e.left,
|
||||||
|
top = basePaddingTop + e.top,
|
||||||
|
right = basePaddingRight + e.right,
|
||||||
|
bottom = basePaddingBottom + e.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun forceRelayoutAndInsets() {
|
||||||
|
root.post {
|
||||||
|
ViewCompat.requestApplyInsets(root)
|
||||||
|
applyPadding()
|
||||||
|
root.post {
|
||||||
|
ViewCompat.requestApplyInsets(root)
|
||||||
|
applyPadding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enterFullscreen(allowCutoutShortEdges: Boolean = true) {
|
||||||
|
fullscreen = true
|
||||||
|
if (allowCutoutShortEdges) {
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
layoutInDisplayCutoutMode =
|
||||||
|
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller.hide(Type.systemBars())
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exitFullscreen() {
|
||||||
|
fullscreen = false
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
|
||||||
|
}
|
||||||
|
controller.show(Type.systemBars())
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onConfigurationChanged() {
|
||||||
|
forceRelayoutAndInsets()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLightSystemBarAppearance(lightStatus: Boolean, lightNav: Boolean) {
|
||||||
|
controller.isAppearanceLightStatusBars = lightStatus
|
||||||
|
controller.isAppearanceLightNavigationBars = lightNav
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun attach(activity: Activity, root: ViewGroup): RootInsetsController {
|
||||||
|
return RootInsetsController(activity, activity.window, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,14 +6,16 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.activities.IWithResultLauncher
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.ManageTabsActivity
|
import com.futo.platformplayer.activities.ManageTabsActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
import com.futo.platformplayer.activities.PolycentricHomeActivity
|
||||||
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
import com.futo.platformplayer.activities.PolycentricProfileActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
import com.futo.platformplayer.activities.SyncHomeActivity
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.constructs.Event0
|
import com.futo.platformplayer.constructs.Event0
|
||||||
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
import com.futo.platformplayer.fragment.mainactivity.bottombar.MenuBottomBarFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
|
||||||
@@ -24,10 +26,12 @@ import com.futo.platformplayer.states.StateCache
|
|||||||
import com.futo.platformplayer.states.StateMeta
|
import com.futo.platformplayer.states.StateMeta
|
||||||
import com.futo.platformplayer.states.StatePayment
|
import com.futo.platformplayer.states.StatePayment
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
import com.futo.platformplayer.states.StateUpdate
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
import com.futo.platformplayer.stores.FragmentedStorage
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
import com.futo.platformplayer.stores.FragmentedStorageFileJson
|
||||||
import com.futo.platformplayer.views.FeedStyle
|
import com.futo.platformplayer.views.FeedStyle
|
||||||
|
import com.futo.platformplayer.views.fields.AdvancedField
|
||||||
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
import com.futo.platformplayer.views.fields.DropdownFieldOptionsId
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
import com.futo.platformplayer.views.fields.FieldForm
|
||||||
import com.futo.platformplayer.views.fields.FormField
|
import com.futo.platformplayer.views.fields.FormField
|
||||||
@@ -39,11 +43,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
data class MenuBottomBarSetting(val id: Int, var enabled: Boolean);
|
||||||
|
|
||||||
@@ -57,10 +61,19 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Transient
|
@Transient
|
||||||
val onTabsChanged = Event0();
|
val onTabsChanged = Event0();
|
||||||
|
|
||||||
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -6)
|
@FormField(R.string.sync_grayjay, FieldForm.BUTTON, R.string.sync_grayjay_description, -8)
|
||||||
|
@FormFieldButton(R.drawable.ic_update)
|
||||||
|
fun syncGrayjay() {
|
||||||
|
StateApp?.instance?.activity?.let {
|
||||||
|
it.startActivity(Intent(it, SyncHomeActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.manage_polycentric_identity, FieldForm.BUTTON, R.string.manage_your_polycentric_identity, -7)
|
||||||
@FormFieldButton(R.drawable.ic_person)
|
@FormFieldButton(R.drawable.ic_person)
|
||||||
fun managePolycentricIdentity() {
|
fun managePolycentricIdentity() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp?.instance?.activity?.let {
|
||||||
if (StatePolycentric.instance.enabled) {
|
if (StatePolycentric.instance.enabled) {
|
||||||
if (StatePolycentric.instance.processHandle != null) {
|
if (StatePolycentric.instance.processHandle != null) {
|
||||||
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
it.startActivity(Intent(it, PolycentricProfileActivity::class.java));
|
||||||
@@ -73,22 +86,22 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -5)
|
@FormField(R.string.show_faq, FieldForm.BUTTON, R.string.get_answers_to_common_questions, -6)
|
||||||
@FormFieldButton(R.drawable.ic_quiz)
|
@FormFieldButton(R.drawable.ic_quiz)
|
||||||
fun openFAQ() {
|
fun openFAQ() {
|
||||||
try {
|
try {
|
||||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(Settings.URL_FAQ))
|
||||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
//Ignored
|
//Ignored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -4)
|
@FormField(R.string.show_issues, FieldForm.BUTTON, R.string.a_list_of_user_reported_and_self_reported_issues, -5)
|
||||||
@FormFieldButton(R.drawable.ic_data_alert)
|
@FormFieldButton(R.drawable.ic_data_alert)
|
||||||
fun openIssues() {
|
fun openIssues() {
|
||||||
try {
|
try {
|
||||||
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/futo-org/grayjay-android/issues"))
|
||||||
SettingsActivity.getActivity()?.startActivity(browserIntent);
|
StateApp?.instance?.activity?.startActivity(browserIntent);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
//Ignored
|
//Ignored
|
||||||
}
|
}
|
||||||
@@ -115,11 +128,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -3)
|
@FormField(R.string.manage_tabs, FieldForm.BUTTON, R.string.change_tabs_visible_on_the_home_screen, -4)
|
||||||
@FormFieldButton(R.drawable.ic_tabs)
|
@FormFieldButton(R.drawable.ic_tabs)
|
||||||
fun manageTabs() {
|
fun manageTabs() {
|
||||||
try {
|
try {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp?.instance?.activity?.let {
|
||||||
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
it.startActivity(Intent(it, ManageTabsActivity::class.java));
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -129,25 +142,46 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -2)
|
@FormField(R.string.import_data, FieldForm.BUTTON, R.string.import_data_description, -3)
|
||||||
@FormFieldButton(R.drawable.ic_move_up)
|
@FormFieldButton(R.drawable.ic_move_up)
|
||||||
fun import() {
|
fun import() {
|
||||||
val act = SettingsActivity.getActivity() ?: return;
|
val act = StateApp.instance.activity ?: return;
|
||||||
val intent = MainActivity.getImportOptionsIntent(act);
|
val intent = MainActivity.getImportOptionsIntent(act);
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK;
|
|
||||||
act.startActivity(intent);
|
act.startActivity(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -1)
|
@FormField(R.string.link_handling, FieldForm.BUTTON, R.string.allow_grayjay_to_handle_links, -2)
|
||||||
@FormFieldButton(R.drawable.ic_link)
|
@FormFieldButton(R.drawable.ic_link)
|
||||||
fun manageLinks() {
|
fun manageLinks() {
|
||||||
try {
|
try {
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
StateApp.instance.activity?.let { UIDialogs.showUrlHandlingPrompt(it) }
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to show url handling prompt", e)
|
Logger.e(TAG, "Failed to show url handling prompt", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*@FormField(R.string.disable_battery_optimization, FieldForm.BUTTON, R.string.click_to_go_to_battery_optimization_settings_disabling_battery_optimization_will_prevent_the_os_from_killing_media_sessions, -1)
|
||||||
|
@FormFieldButton(R.drawable.battery_full_24px)
|
||||||
|
fun ignoreBatteryOptimization() {
|
||||||
|
StateApp.instance.activity?.let {
|
||||||
|
val intent = Intent()
|
||||||
|
val packageName = it.packageName
|
||||||
|
val pm = it.getSystemService(POWER_SERVICE) as PowerManager;
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(packageName)) {
|
||||||
|
intent.setAction(android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
|
||||||
|
intent.setData(Uri.parse("package:$packageName"))
|
||||||
|
it.startActivity(intent)
|
||||||
|
UIDialogs.toast(it, "Please ignore battery optimizations for Grayjay")
|
||||||
|
} else {
|
||||||
|
UIDialogs.toast(it, "Battery optimizations already disabled for Grayjay")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.advanced_settings, FieldForm.TOGGLE, R.string.advanced_settings_description, -1, "advancedSettings")
|
||||||
|
var advancedSettings: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.language, "group", -1, 0)
|
@FormField(R.string.language, "group", -1, 0)
|
||||||
var language = LanguageSettings();
|
var language = LanguageSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -169,6 +203,8 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "zh";
|
8 -> "zh";
|
||||||
9 -> "ru";
|
9 -> "ru";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
|
11 -> "it";
|
||||||
|
12 -> "tr";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,7 +214,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var home = HomeSettings();
|
var home = HomeSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class HomeSettings {
|
class HomeSettings {
|
||||||
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 5)
|
@FormField(R.string.feed_style, FieldForm.DROPDOWN, R.string.may_require_restart, 3)
|
||||||
@DropdownFieldOptionsId(R.array.feed_style)
|
@DropdownFieldOptionsId(R.array.feed_style)
|
||||||
var homeFeedStyle: Int = 1;
|
var homeFeedStyle: Int = 1;
|
||||||
|
|
||||||
@@ -189,10 +225,16 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
return FeedStyle.THUMBNAIL;
|
return FeedStyle.THUMBNAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.show_home_filters, FieldForm.TOGGLE, R.string.show_home_filters_description, 4)
|
||||||
|
var showHomeFilters: Boolean = true;
|
||||||
|
@FormField(R.string.show_home_filters_plugin_names, FieldForm.TOGGLE, R.string.show_home_filters_plugin_names_description, 5)
|
||||||
|
var showHomeFiltersPluginNames: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
var progressBar: Boolean = true;
|
var progressBar: Boolean = true;
|
||||||
|
|
||||||
@@ -202,7 +244,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun clearHidden() {
|
fun clearHidden() {
|
||||||
StateMeta.instance.removeAllHiddenCreators();
|
StateMeta.instance.removeAllHiddenCreators();
|
||||||
StateMeta.instance.removeAllHiddenVideos();
|
StateMeta.instance.removeAllHiddenVideos();
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
UIDialogs.toast(it, "Creators and videos should show up again");
|
UIDialogs.toast(it, "Creators and videos should show up again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -221,12 +263,17 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.feed_style)
|
@DropdownFieldOptionsId(R.array.feed_style)
|
||||||
var searchFeedStyle: Int = 1;
|
var searchFeedStyle: Int = 1;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 5)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
var progressBar: Boolean = true;
|
var progressBar: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.hide_hidden_from_search, FieldForm.TOGGLE, R.string.hide_hidden_from_search_description, 7)
|
||||||
|
var hidefromSearch: Boolean = false;
|
||||||
|
|
||||||
|
|
||||||
fun getSearchFeedStyle(): FeedStyle {
|
fun getSearchFeedStyle(): FeedStyle {
|
||||||
if(searchFeedStyle == 0)
|
if(searchFeedStyle == 0)
|
||||||
@@ -242,6 +289,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class ChannelSettings {
|
class ChannelSettings {
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 6)
|
||||||
var progressBar: Boolean = true;
|
var progressBar: Boolean = true;
|
||||||
}
|
}
|
||||||
@@ -264,16 +312,23 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
@FormField(R.string.show_subscription_group, FieldForm.TOGGLE, R.string.show_subscription_group_description, 5)
|
||||||
var showSubscriptionGroups: Boolean = true;
|
var showSubscriptionGroups: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.use_subscription_exchange, FieldForm.TOGGLE, R.string.use_subscription_exchange_description, 6)
|
||||||
|
var useSubscriptionExchange: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
@FormField(R.string.preview_feed_items, FieldForm.TOGGLE, R.string.preview_feed_items_description, 6)
|
||||||
var previewFeedItems: Boolean = true;
|
var previewFeedItems: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
|
@FormField(R.string.progress_bar, FieldForm.TOGGLE, R.string.progress_bar_description, 7)
|
||||||
var progressBar: Boolean = true;
|
var progressBar: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
|
@FormField(R.string.fetch_on_app_boot, FieldForm.TOGGLE, R.string.shortly_after_opening_the_app_start_fetching_subscriptions, 8)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var fetchOnAppBoot: Boolean = true;
|
var fetchOnAppBoot: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
|
@FormField(R.string.fetch_on_tab_opened, FieldForm.TOGGLE, R.string.fetch_on_tab_opened_description, 9)
|
||||||
var fetchOnTabOpen: Boolean = true;
|
var fetchOnTabOpen: Boolean = true;
|
||||||
|
|
||||||
@@ -304,21 +359,24 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
|
@FormField(R.string.show_watch_metrics, FieldForm.TOGGLE, R.string.show_watch_metrics_description, 12)
|
||||||
var showWatchMetrics: Boolean = false;
|
var showWatchMetrics: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
|
@FormField(R.string.track_playtime_locally, FieldForm.TOGGLE, R.string.track_playtime_locally_description, 13)
|
||||||
var allowPlaytimeTracking: Boolean = true;
|
var allowPlaytimeTracking: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
@FormField(R.string.always_reload_from_cache, FieldForm.TOGGLE, R.string.always_reload_from_cache_description, 14)
|
||||||
var alwaysReloadFromCache: Boolean = false;
|
var alwaysReloadFromCache: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
@FormField(R.string.peek_channel_contents, FieldForm.TOGGLE, R.string.peek_channel_contents_description, 15)
|
||||||
var peekChannelContents: Boolean = false;
|
var peekChannelContents: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON, R.string.clear_channel_cache_description, 16)
|
||||||
fun clearChannelCache() {
|
fun clearChannelCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Started clearing..");
|
UIDialogs.toast(StateApp.instance.activity!!, "Started clearing..");
|
||||||
StateCache.instance.clear();
|
StateCache.instance.clear();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Finished clearing");
|
UIDialogs.toast(StateApp.instance.activity!!, "Finished clearing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,11 +384,11 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var playback = PlaybackSettings();
|
var playback = PlaybackSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class PlaybackSettings {
|
class PlaybackSettings {
|
||||||
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, 0)
|
@FormField(R.string.primary_language, FieldForm.DROPDOWN, -1, -2)
|
||||||
@DropdownFieldOptionsId(R.array.audio_languages)
|
@DropdownFieldOptionsId(R.array.audio_languages)
|
||||||
var primaryLanguage: Int = 0;
|
var primaryLanguage: Int = 0;
|
||||||
|
|
||||||
fun getPrimaryLanguage(context: Context): String? {
|
fun getPrimaryLanguage(context: Context? = null): String? {
|
||||||
return when(primaryLanguage) {
|
return when(primaryLanguage) {
|
||||||
0 -> "en";
|
0 -> "en";
|
||||||
1 -> "es";
|
1 -> "es";
|
||||||
@@ -343,17 +401,24 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
8 -> "id";
|
8 -> "id";
|
||||||
9 -> "hi";
|
9 -> "hi";
|
||||||
10 -> "ar";
|
10 -> "ar";
|
||||||
11 -> "tu";
|
11 -> "tr";
|
||||||
12 -> "ru";
|
12 -> "ru";
|
||||||
13 -> "pt";
|
13 -> "pt";
|
||||||
14 -> "zh";
|
14 -> "zh";
|
||||||
|
15 -> "it";
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.sticky_subtitles, FieldForm.TOGGLE, R.string.sticky_subtitles_description, -1)
|
||||||
|
var stickySubtitles: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.prefer_original_audio, FieldForm.TOGGLE, R.string.prefer_original_audio_description, -1)
|
||||||
|
var preferOriginalAudio: Boolean = true;
|
||||||
|
|
||||||
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
//= context.resources.getStringArray(R.array.audio_languages)[primaryLanguage];
|
||||||
|
|
||||||
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 1)
|
@FormField(R.string.default_playback_speed, FieldForm.DROPDOWN, -1, 0)
|
||||||
@DropdownFieldOptionsId(R.array.playback_speeds)
|
@DropdownFieldOptionsId(R.array.playback_speeds)
|
||||||
var defaultPlaybackSpeed: Int = 3;
|
var defaultPlaybackSpeed: Int = 3;
|
||||||
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
fun getDefaultPlaybackSpeed(): Float = when(defaultPlaybackSpeed) {
|
||||||
@@ -366,46 +431,44 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
6 -> 1.75f;
|
6 -> 1.75f;
|
||||||
7 -> 2.0f;
|
7 -> 2.0f;
|
||||||
8 -> 2.25f;
|
8 -> 2.25f;
|
||||||
|
9 -> 2.5f;
|
||||||
|
10 -> 2.75f;
|
||||||
|
11 -> 3.0f;
|
||||||
else -> 1.0f;
|
else -> 1.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 2)
|
@FormField(R.string.preferred_quality, FieldForm.DROPDOWN, R.string.preferred_quality_description, 1)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredQuality: Int = 0;
|
var preferredQuality: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 3)
|
@FormField(R.string.preferred_metered_quality, FieldForm.DROPDOWN, R.string.preferred_metered_quality_description, 2)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredMeteredQuality: Int = 0;
|
var preferredMeteredQuality: Int = 0;
|
||||||
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
fun getPreferredQualityPixelCount(): Int = preferedQualityToPixels(preferredQuality);
|
||||||
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
fun getPreferredMeteredQualityPixelCount(): Int = preferedQualityToPixels(preferredMeteredQuality);
|
||||||
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
fun getCurrentPreferredQualityPixelCount(): Int = if(!StateApp.instance.isCurrentMetered()) getPreferredQualityPixelCount() else getPreferredMeteredQualityPixelCount();
|
||||||
|
|
||||||
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 4)
|
@FormField(R.string.preferred_preview_quality, FieldForm.DROPDOWN, R.string.preferred_preview_quality_description, 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
var preferredPreviewQuality: Int = 5;
|
var preferredPreviewQuality: Int = 5;
|
||||||
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
fun getPreferredPreviewQualityPixelCount(): Int = preferedQualityToPixels(preferredPreviewQuality);
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate, FieldForm.DROPDOWN, -1, 5)
|
@AdvancedField
|
||||||
@DropdownFieldOptionsId(R.array.system_enabled_disabled_array)
|
@FormField(R.string.simplify_sources, FieldForm.TOGGLE, R.string.simplify_sources_description, 4)
|
||||||
var autoRotate: Int = 2;
|
var simplifySources: Boolean = true;
|
||||||
|
|
||||||
fun isAutoRotate() = autoRotate == 1 || (autoRotate == 2 && StateApp.instance.getCurrentSystemAutoRotate());
|
@AdvancedField
|
||||||
|
@FormField(R.string.always_allow_reverse_landscape_auto_rotate, FieldForm.TOGGLE, R.string.always_allow_reverse_landscape_auto_rotate_description, 5)
|
||||||
|
var alwaysAllowReverseLandscapeAutoRotate: Boolean = true
|
||||||
|
|
||||||
@FormField(R.string.auto_rotate_dead_zone, FieldForm.DROPDOWN, R.string.this_prevents_the_device_from_rotating_within_the_given_amount_of_degrees, 6)
|
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 6)
|
||||||
@DropdownFieldOptionsId(R.array.auto_rotate_dead_zone)
|
|
||||||
var autoRotateDeadZone: Int = 0;
|
|
||||||
|
|
||||||
fun getAutoRotateDeadZoneDegrees(): Int {
|
|
||||||
return autoRotateDeadZone * 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@FormField(R.string.background_behavior, FieldForm.DROPDOWN, -1, 7)
|
|
||||||
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
@DropdownFieldOptionsId(R.array.player_background_behavior)
|
||||||
var backgroundPlay: Int = 2;
|
var backgroundPlay: Int = 2;
|
||||||
|
|
||||||
fun isBackgroundContinue() = backgroundPlay == 1;
|
fun isBackgroundContinue() = backgroundPlay == 1;
|
||||||
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
|
fun isBackgroundPictureInPicture() = backgroundPlay == 2;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
|
@FormField(R.string.resume_after_preview, FieldForm.DROPDOWN, R.string.when_watching_a_video_in_preview_mode_resume_at_the_position_when_opening_the_video_code, 7)
|
||||||
@DropdownFieldOptionsId(R.array.resume_after_preview)
|
@DropdownFieldOptionsId(R.array.resume_after_preview)
|
||||||
var resumeAfterPreview: Int = 1;
|
var resumeAfterPreview: Int = 1;
|
||||||
@@ -432,14 +495,10 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
@FormField(R.string.live_chat_webview, FieldForm.TOGGLE, R.string.use_the_live_chat_web_window_when_available_over_native_implementation, 9)
|
||||||
var useLiveChatWindow: Boolean = true;
|
var useLiveChatWindow: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@FormField(R.string.background_switch_audio, FieldForm.TOGGLE, R.string.background_switch_audio_description, 10)
|
|
||||||
var backgroundSwitchToAudio: Boolean = true;
|
|
||||||
|
|
||||||
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
|
@FormField(R.string.restart_after_audio_focus_loss, FieldForm.DROPDOWN, R.string.restart_playback_when_gaining_audio_focus_after_a_loss, 11)
|
||||||
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
@DropdownFieldOptionsId(R.array.restart_playback_after_loss)
|
||||||
var restartPlaybackAfterLoss: Int = 1;
|
var restartPlaybackAfterLoss: Int = 1;
|
||||||
@@ -450,18 +509,144 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
@FormField(R.string.full_screen_portrait, FieldForm.TOGGLE, R.string.allow_full_screen_portrait, 13)
|
||||||
var fullscreenPortrait: Boolean = false;
|
var fullscreenPortrait: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.reverse_portrait, FieldForm.TOGGLE, R.string.reverse_portrait_description, 14)
|
||||||
|
var reversePortrait: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.prefer_webm, FieldForm.TOGGLE, R.string.prefer_webm_description, 18)
|
||||||
|
var preferWebmVideo: Boolean = false;
|
||||||
|
@FormField(R.string.prefer_webm_audio, FieldForm.TOGGLE, R.string.prefer_webm_audio_description, 19)
|
||||||
|
var preferWebmAudio: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.allow_under_cutout, FieldForm.TOGGLE, R.string.allow_under_cutout_description, 20)
|
||||||
|
var allowVideoToGoUnderCutout: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.autoplay, FieldForm.TOGGLE, R.string.autoplay, 21)
|
||||||
|
var autoplay: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.delete_watchlist_on_finish, FieldForm.TOGGLE, R.string.delete_watchlist_on_finish_description, 22)
|
||||||
|
var deleteFromWatchLaterAuto: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.seek_offset, FieldForm.DROPDOWN, R.string.seek_offset_description, 23)
|
||||||
|
@DropdownFieldOptionsId(R.array.seek_offset_duration)
|
||||||
|
var seekOffset: Int = 2;
|
||||||
|
|
||||||
|
fun getSeekOffset(): Long {
|
||||||
|
return when(seekOffset) {
|
||||||
|
0 -> 3_000L;
|
||||||
|
1 -> 5_000L;
|
||||||
|
2 -> 10_000L;
|
||||||
|
3 -> 20_000L;
|
||||||
|
4 -> 30_000L;
|
||||||
|
5 -> 60_000L;
|
||||||
|
else -> 10_000L;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.min_playback_speed, FieldForm.DROPDOWN, R.string.min_playback_speed_description, 25)
|
||||||
|
@DropdownFieldOptionsId(R.array.min_playback_speed)
|
||||||
|
var minimumPlaybackSpeed: Int = 0;
|
||||||
|
@FormField(R.string.max_playback_speed, FieldForm.DROPDOWN, R.string.max_playback_speed_description, 26)
|
||||||
|
@DropdownFieldOptionsId(R.array.max_playback_speed)
|
||||||
|
var maximumPlaybackSpeed: Int = 2;
|
||||||
|
@FormField(R.string.step_playback_speed, FieldForm.DROPDOWN, R.string.step_playback_speed_description, 26)
|
||||||
|
@DropdownFieldOptionsId(R.array.step_playback_speed)
|
||||||
|
var stepPlaybackSpeed: Int = 1;
|
||||||
|
|
||||||
|
fun getPlaybackSpeedStep(): Double {
|
||||||
|
return when(stepPlaybackSpeed) {
|
||||||
|
0 -> 0.05
|
||||||
|
1 -> 0.1
|
||||||
|
2 -> 0.25
|
||||||
|
else -> 0.1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun getPlaybackSpeeds(): List<Double> {
|
||||||
|
val playbackSpeeds = mutableListOf<Double>();
|
||||||
|
playbackSpeeds.add(1.0);
|
||||||
|
val minSpeed = when(minimumPlaybackSpeed) {
|
||||||
|
0 -> 0.25
|
||||||
|
1 -> 0.5
|
||||||
|
2 -> 1.0
|
||||||
|
else -> 0.25
|
||||||
|
}
|
||||||
|
val maxSpeed = when(maximumPlaybackSpeed) {
|
||||||
|
0 -> 2.0
|
||||||
|
1 -> 2.25
|
||||||
|
2 -> 3.0
|
||||||
|
3 -> 4.0
|
||||||
|
4 -> 5.0
|
||||||
|
else -> 2.25;
|
||||||
|
}
|
||||||
|
var testSpeed = 1.0;
|
||||||
|
|
||||||
|
while(testSpeed > minSpeed) {
|
||||||
|
val nextSpeed = (testSpeed - 0.25) as Double;
|
||||||
|
testSpeed = Math.max(nextSpeed, minSpeed);
|
||||||
|
playbackSpeeds.add(testSpeed);
|
||||||
|
}
|
||||||
|
testSpeed = 1.0;
|
||||||
|
while(testSpeed < maxSpeed) {
|
||||||
|
val nextSpeed = (testSpeed + if(testSpeed < 2) 0.25 else 1.0) as Double;
|
||||||
|
testSpeed = Math.min(nextSpeed, maxSpeed);
|
||||||
|
playbackSpeeds.add(testSpeed);
|
||||||
|
}
|
||||||
|
playbackSpeeds.sort();
|
||||||
|
return playbackSpeeds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.hold_playback_speed, FieldForm.DROPDOWN, R.string.hold_playback_speed_description, 27)
|
||||||
|
@DropdownFieldOptionsId(R.array.hold_playback_speeds)
|
||||||
|
var holdPlaybackSpeed: Int = 4;
|
||||||
|
|
||||||
|
fun getHoldPlaybackSpeed(): Double {
|
||||||
|
return when(holdPlaybackSpeed) {
|
||||||
|
0 -> 1.0
|
||||||
|
1 -> 1.25
|
||||||
|
2 -> 1.5
|
||||||
|
3 -> 1.75
|
||||||
|
4 -> 2.0
|
||||||
|
5 -> 2.25
|
||||||
|
6 -> 2.5
|
||||||
|
7 -> 2.75
|
||||||
|
8 -> 3.0
|
||||||
|
else -> 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.shorts_pregenerate, FieldForm.TOGGLE, R.string.shorts_pregenerate_description, 28)
|
||||||
|
var shortsPregenerate: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.shorts_fit_video, FieldForm.TOGGLE, R.string.shorts_fit_video_description, 29)
|
||||||
|
@FormFieldWarning(R.string.shorts_fit_video_warning)
|
||||||
|
var shortsFitVideo: Boolean = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
@FormField(R.string.comments, "group", R.string.comments_description, 6)
|
||||||
var comments = CommentSettings();
|
var comments = CommentSettings();
|
||||||
@Serializable
|
@Serializable
|
||||||
class CommentSettings {
|
class CommentSettings {
|
||||||
|
var didAskPolycentricDefault: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
@FormField(R.string.default_comment_section, FieldForm.DROPDOWN, -1, 0)
|
||||||
@DropdownFieldOptionsId(R.array.comment_sections)
|
@DropdownFieldOptionsId(R.array.comment_sections)
|
||||||
var defaultCommentSection: Int = 0;
|
var defaultCommentSection: Int = 2;
|
||||||
|
|
||||||
|
@FormField(R.string.default_recommendations, FieldForm.TOGGLE, R.string.default_recommendations_description, 0)
|
||||||
|
var recommendationsDefault: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.hide_recommendations, FieldForm.TOGGLE, R.string.hide_recommendations_description, 0)
|
||||||
|
var hideRecommendations: Boolean = false;
|
||||||
|
|
||||||
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
@FormField(R.string.bad_reputation_comments_fading, FieldForm.TOGGLE, R.string.bad_reputation_comments_fading_description, 0)
|
||||||
var badReputationCommentsFading: Boolean = true;
|
var badReputationCommentsFading: Boolean = true;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
@FormField(R.string.downloads, "group", R.string.configure_downloading_of_videos, 7)
|
||||||
@@ -492,10 +677,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var preferredAudioQuality: Int = 1;
|
var preferredAudioQuality: Int = 1;
|
||||||
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
|
fun isHighBitrateDefault(): Boolean = preferredAudioQuality > 0;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
|
@FormField(R.string.byte_range_download, FieldForm.TOGGLE, R.string.attempt_to_utilize_byte_ranges, 4)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var byteRangeDownload: Boolean = true;
|
var byteRangeDownload: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
|
@FormField(R.string.byte_range_concurrency, FieldForm.DROPDOWN, R.string.number_of_concurrent_threads_to_multiply_download_speeds_from_throttled_sources, 5)
|
||||||
@DropdownFieldOptionsId(R.array.thread_count)
|
@DropdownFieldOptionsId(R.array.thread_count)
|
||||||
var byteRangeConcurrency: Int = 3;
|
var byteRangeConcurrency: Int = 3;
|
||||||
@@ -510,7 +697,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
class Browsing {
|
class Browsing {
|
||||||
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
|
@FormField(R.string.enable_video_cache, FieldForm.TOGGLE, R.string.cache_to_quickly_load_previously_fetched_videos, 0)
|
||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var videoCache: Boolean = true;
|
var videoCache: Boolean = false; //Temporary default disabled to prevent ui freeze?
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
@FormField(R.string.casting, "group", R.string.configure_casting, 9)
|
||||||
@@ -525,6 +712,21 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = FlexibleBooleanSerializer::class)
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
var keepScreenOn: Boolean = true;
|
var keepScreenOn: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.always_proxy_requests, FieldForm.TOGGLE, R.string.always_proxy_requests_description, 3)
|
||||||
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
|
var alwaysProxyRequests: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.allow_ipv6, FieldForm.TOGGLE, R.string.allow_ipv6_description, 4)
|
||||||
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
|
var allowIpv6: Boolean = true;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.allow_ipv4, FieldForm.TOGGLE, R.string.allow_ipv4_description, 5)
|
||||||
|
@Serializable(with = FlexibleBooleanSerializer::class)
|
||||||
|
var allowLinkLocalIpv4: Boolean = false;
|
||||||
|
|
||||||
/*TODO: Should we have a different casting quality?
|
/*TODO: Should we have a different casting quality?
|
||||||
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
@FormField("Preferred Casting Quality", FieldForm.DROPDOWN, "", 3)
|
||||||
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
@DropdownFieldOptionsId(R.array.preferred_quality_array)
|
||||||
@@ -557,7 +759,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
try {
|
try {
|
||||||
if (!Logger.submitLogs()) {
|
if (!Logger.submitLogs()) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.please_enable_logging_to_submit_logs)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
@@ -574,7 +776,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
@FormField(R.string.reset_announcements, FieldForm.BUTTON, R.string.reset_hidden_announcements, 1)
|
||||||
fun resetAnnouncements() {
|
fun resetAnnouncements() {
|
||||||
StateAnnouncement.instance.resetAnnouncements();
|
StateAnnouncement.instance.resetAnnouncements();
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
StateApp.instance.activity?.let { UIDialogs.toast(it, it.getString(R.string.announcements_reset)); };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -587,11 +789,18 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
@FormField(R.string.plugins, FieldForm.GROUP, -1, 13)
|
||||||
@Transient
|
|
||||||
var plugins = Plugins();
|
var plugins = Plugins();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Plugins {
|
class Plugins {
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.check_disabled_plugin_updates, FieldForm.TOGGLE, R.string.check_disabled_plugin_updates_description, -1)
|
||||||
|
var checkDisabledPluginsForUpdates: Boolean = false;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.clear_cookies_after_login, FieldForm.TOGGLE, R.string.clear_cookies_after_login_desc, 0)
|
||||||
|
var clearCookiesAfterLogin: Boolean = false;
|
||||||
|
@AdvancedField
|
||||||
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
@FormField(R.string.clear_cookies_on_logout, FieldForm.TOGGLE, R.string.clears_cookies_when_you_log_out, 0)
|
||||||
var clearCookiesOnLogout: Boolean = true;
|
var clearCookiesOnLogout: Boolean = true;
|
||||||
|
|
||||||
@@ -600,6 +809,12 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
val cookieManager: CookieManager = CookieManager.getInstance();
|
val cookieManager: CookieManager = CookieManager.getInstance();
|
||||||
cookieManager.removeAllCookies(null);
|
cookieManager.removeAllCookies(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shouldClearWebviewCookies(): Boolean {
|
||||||
|
return clearCookiesAfterLogin;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
/*@FormField(R.string.reinstall_embedded_plugins, FieldForm.BUTTON, R.string.also_removes_any_data_related_plugin_like_login_or_settings, 1)
|
||||||
fun reinstallEmbedded() {
|
fun reinstallEmbedded() {
|
||||||
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull!!.launch(Dispatchers.IO) {
|
||||||
@@ -637,13 +852,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
@FormField(R.string.change_external_general_directory, FieldForm.BUTTON, R.string.change_the_external_directory_for_general_files, 3)
|
||||||
fun changeStorageGeneral() {
|
fun changeStorageGeneral() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.changeExternalGeneralDirectory(it);
|
StateApp.instance.changeExternalGeneralDirectory(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
@FormField(R.string.change_external_downloads_directory, FieldForm.BUTTON, R.string.change_the_external_storage_for_download_files, 4)
|
||||||
fun changeStorageDownload() {
|
fun changeStorageDownload() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.changeExternalDownloadDirectory(it);
|
StateApp.instance.changeExternalDownloadDirectory(it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -652,7 +867,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
fun clearStorageDownload() {
|
fun clearStorageDownload() {
|
||||||
Settings.instance.storage.storage_download = null;
|
Settings.instance.storage.storage_download = null;
|
||||||
Settings.instance.save();
|
Settings.instance.save();
|
||||||
SettingsActivity.getActivity()?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
StateApp.instance.activity?.let { UIDialogs.toast(it, "Cleared download storage directory") };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,9 +880,9 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
@DropdownFieldOptionsId(R.array.auto_update_when_array)
|
||||||
var check: Int = 0;
|
var check: Int = 0;
|
||||||
|
|
||||||
@FormField(R.string.background_download, FieldForm.DROPDOWN, R.string.configure_if_background_download_should_be_used, 1)
|
@FormField(R.string.background_download, FieldForm.TOGGLE, R.string.configure_if_background_download_should_be_used, 1)
|
||||||
@DropdownFieldOptionsId(R.array.background_download)
|
//@DropdownFieldOptionsId(R.array.background_download)
|
||||||
var backgroundDownload: Int = 0;
|
var shouldBackgroundDownload: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
@FormField(R.string.download_when, FieldForm.DROPDOWN, R.string.configure_when_updates_should_be_downloaded, 2)
|
||||||
@DropdownFieldOptionsId(R.array.when_download)
|
@DropdownFieldOptionsId(R.array.when_download)
|
||||||
@@ -689,13 +904,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
@FormField(R.string.manual_check, FieldForm.BUTTON, R.string.manually_check_for_updates, 3)
|
||||||
fun manualCheck() {
|
fun manualCheck() {
|
||||||
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
if (!BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
StateUpdate.instance.checkForUpdates(it, true)
|
StateUpdate.instance.checkForUpdates(it, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
try {
|
try {
|
||||||
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
it.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${it.packageName}")))
|
||||||
} catch (e: ActivityNotFoundException) {
|
} catch (e: ActivityNotFoundException) {
|
||||||
@@ -707,7 +922,7 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
@FormField(R.string.view_changelog, FieldForm.BUTTON, R.string.review_the_current_and_past_changelogs, 4)
|
||||||
fun viewChangelog() {
|
fun viewChangelog() {
|
||||||
SettingsActivity.getActivity()?.let {
|
StateApp.instance.activity?.let {
|
||||||
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
UIDialogs.toast(it.getString(R.string.retrieving_changelog));
|
||||||
|
|
||||||
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
StateApp.instance.scopeOrNull?.launch(Dispatchers.IO) {
|
||||||
@@ -748,21 +963,34 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable(with = OffsetDateTimeSerializer::class)
|
@Serializable(with = OffsetDateTimeSerializer::class)
|
||||||
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
var lastAutoBackupTime: OffsetDateTime = OffsetDateTime.MIN;
|
||||||
var didAskAutoBackup: Boolean = false;
|
var didAskAutoBackup: Boolean = false;
|
||||||
|
var autoBackupEnabled: Boolean = false
|
||||||
var autoBackupPassword: String? = null;
|
var autoBackupPassword: String? = null;
|
||||||
fun shouldAutomaticBackup() = autoBackupPassword != null;
|
fun shouldAutomaticBackup() = autoBackupEnabled
|
||||||
|
|
||||||
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
@FormField(R.string.automatic_backup, FieldForm.READONLYTEXT, -1, 0)
|
||||||
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
val automaticBackupText get() = if(!shouldAutomaticBackup()) "None" else "Every Day";
|
||||||
|
|
||||||
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
@FormField(R.string.set_automatic_backup, FieldForm.BUTTON, R.string.configure_daily_backup_in_case_of_catastrophic_failure, 1)
|
||||||
fun configureAutomaticBackup() {
|
fun configureAutomaticBackup() {
|
||||||
UIDialogs.showAutomaticBackupDialog(SettingsActivity.getActivity()!!, autoBackupPassword != null) {
|
StateApp.instance.activity?.let { activity ->
|
||||||
SettingsActivity.getActivity()?.reloadSettings();
|
if(!Settings.instance.storage.isStorageMainValid(activity)) {
|
||||||
};
|
UIDialogs.toast("Missing general directory")
|
||||||
|
StateApp.instance.changeExternalGeneralDirectory(activity) {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
UIDialogs.showAutomaticBackupDialog(activity) {
|
||||||
|
SettingsFragment.currentView?.reloadSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
@FormField(R.string.restore_automatic_backup, FieldForm.BUTTON, R.string.restore_a_previous_automatic_backup, 2)
|
||||||
fun restoreAutomaticBackup() {
|
fun restoreAutomaticBackup() {
|
||||||
val activity = SettingsActivity.getActivity()!!
|
val activity = StateApp.instance.activity!!
|
||||||
|
|
||||||
if(!StateBackup.hasAutomaticBackup())
|
if(!StateBackup.hasAutomaticBackup())
|
||||||
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
UIDialogs.toast(activity, activity.getString(R.string.you_don_t_have_any_automatic_backups), false);
|
||||||
@@ -773,12 +1001,13 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
@FormField(R.string.export_data, FieldForm.BUTTON, R.string.creates_a_zip_file_with_your_data_which_can_be_imported_by_opening_it_with_grayjay, 3)
|
||||||
fun export() {
|
fun export() {
|
||||||
val activity = SettingsActivity.getActivity() ?: return;
|
val activity = StateApp.instance.activity ?: return;
|
||||||
UISlideOverlays.showOverlay(activity.overlay, "Select export type", null, {},
|
val fragView = SettingsFragment.currentView ?: return;
|
||||||
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", null, {
|
UISlideOverlays.showOverlay(fragView.overlay, "Select export type", null, {},
|
||||||
|
SlideUpMenuItem(activity, R.drawable.ic_share, "Share", "", tag = null, call = {
|
||||||
StateBackup.shareExternalBackup();
|
StateBackup.shareExternalBackup();
|
||||||
}),
|
}),
|
||||||
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", null, {
|
SlideUpMenuItem(activity, R.drawable.ic_download, "File", "", tag = null, call = {
|
||||||
StateBackup.saveExternalBackup(activity);
|
StateBackup.saveExternalBackup(activity);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -790,14 +1019,34 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
@Serializable
|
@Serializable
|
||||||
class Payment {
|
class Payment {
|
||||||
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
@FormField(R.string.payment_status, FieldForm.READONLYTEXT, -1, 1)
|
||||||
val paymentStatus: String get() = SettingsActivity.getActivity()?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
val paymentStatus: String get() = StateApp.instance.activity?.let { if (StatePayment.instance.hasPaid) it.getString(R.string.paid) else it.getString(R.string.not_paid); } ?: "Unknown";
|
||||||
|
|
||||||
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 2)
|
@FormField(R.string.license_status, FieldForm.BUTTON, R.string.view_license_status, 2)
|
||||||
|
fun viewLicenseStatus() {
|
||||||
|
StateApp.instance.activity?.let {
|
||||||
|
try {
|
||||||
|
if (StatePayment.instance.hasPaid) {
|
||||||
|
val paymentKey = StatePayment.instance.getPaymentKey()
|
||||||
|
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "License activated\n" + paymentKey.first)
|
||||||
|
} else {
|
||||||
|
UIDialogs.showDialogOk(it, R.drawable.ic_paid, "No license activated")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to show license status dialog", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.clear_payment, FieldForm.BUTTON, R.string.deletes_license_keys_from_app, 3)
|
||||||
fun clearPayment() {
|
fun clearPayment() {
|
||||||
StatePayment.instance.clearLicenses();
|
StateApp.instance.activity?.let { context ->
|
||||||
SettingsActivity.getActivity()?.let {
|
UIDialogs.showConfirmationDialog(context, "Are you sure you want to delete your license?", {
|
||||||
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
StatePayment.instance.clearLicenses();
|
||||||
it.reloadSettings();
|
StateApp.instance.activity?.let {
|
||||||
|
UIDialogs.toast(it, it.getString(R.string.licenses_cleared_might_require_app_restart));
|
||||||
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,12 +1055,22 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var other = Other();
|
var other = Other();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Other {
|
class Other {
|
||||||
@FormField(R.string.bypass_rotation_prevention, FieldForm.TOGGLE, R.string.bypass_rotation_prevention_description, 1)
|
@AdvancedField
|
||||||
@FormFieldWarning(R.string.bypass_rotation_prevention_warning)
|
@FormField(R.string.playlist_delete_confirmation, FieldForm.TOGGLE, R.string.playlist_delete_confirmation_description, 2)
|
||||||
var bypassRotationPrevention: Boolean = false;
|
var playlistDeleteConfirmation: Boolean = true;
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.playlist_allow_dups, FieldForm.TOGGLE, R.string.playlist_allow_dups_description, 3)
|
||||||
|
var playlistAllowDups: Boolean = true;
|
||||||
|
|
||||||
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 1)
|
@FormField(R.string.watch_later_add_start, FieldForm.TOGGLE, R.string.watch_later_add_start_description, 4)
|
||||||
|
var watchLaterAddStart: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.enable_polycentric, FieldForm.TOGGLE, R.string.can_be_disabled_when_you_are_experiencing_issues, 5)
|
||||||
var polycentricEnabled: Boolean = true;
|
var polycentricEnabled: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.polycentric_local_cache, FieldForm.TOGGLE, R.string.polycentric_local_cache_description, 7)
|
||||||
|
var polycentricLocalCache: Boolean = true;
|
||||||
|
var showPrivacyModeDialog: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
@FormField(R.string.gesture_controls, FieldForm.GROUP, -1, 19)
|
||||||
@@ -843,7 +1102,72 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
var pan: Boolean = true;
|
var pan: Boolean = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@FormField(R.string.info, FieldForm.GROUP, -1, 20)
|
@FormField(R.string.synchronization, FieldForm.GROUP, -1, 20)
|
||||||
|
var synchronization = Synchronization();
|
||||||
|
@Serializable
|
||||||
|
class Synchronization {
|
||||||
|
@FormField(R.string.enabled, FieldForm.TOGGLE, R.string.enabled_description, 1)
|
||||||
|
var enabled: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.broadcast, FieldForm.TOGGLE, R.string.broadcast_description, 1)
|
||||||
|
var broadcast: Boolean = false;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_discovered, FieldForm.TOGGLE, R.string.connect_discovered_description, 2)
|
||||||
|
var connectDiscovered: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_last, FieldForm.TOGGLE, R.string.connect_last_description, 3)
|
||||||
|
var connectLast: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.discover_through_relay, FieldForm.TOGGLE, R.string.discover_through_relay_description, 3)
|
||||||
|
var discoverThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.pair_through_relay, FieldForm.TOGGLE, R.string.pair_through_relay_description, 3)
|
||||||
|
var pairThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_through_relay, FieldForm.TOGGLE, R.string.connect_through_relay_description, 3)
|
||||||
|
var connectThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.connect_local_direct_through_relay, FieldForm.TOGGLE, R.string.connect_local_direct_through_relay_description, 3)
|
||||||
|
var connectLocalDirectThroughRelay: Boolean = true;
|
||||||
|
|
||||||
|
@FormField(R.string.local_connections, FieldForm.TOGGLE, R.string.local_connections_description, 3)
|
||||||
|
var localConnections: Boolean = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var syncServerUrl: String? = null;
|
||||||
|
@FormField(R.string.relay_server, FieldForm.READONLYTEXT, -1, 6)
|
||||||
|
val syncServer: String get() = if(syncServerUrl?.isBlank() == true) StateSync.RELAY_SERVER else syncServerUrl ?: StateSync.RELAY_SERVER;
|
||||||
|
|
||||||
|
@AdvancedField
|
||||||
|
@FormField(R.string.configure_sync_server, FieldForm.BUTTON, R.string.configure_sync_server_description, 7)
|
||||||
|
fun configureSyncServer() {
|
||||||
|
StateApp.instance.activity?.let { context ->
|
||||||
|
UIDialogs.showDialog(context, R.drawable.device_sync, false,
|
||||||
|
"Enter the url to your relay server",
|
||||||
|
"Using your own relay server requires a proper setup with portforwarding.\nUse at your own risk.",
|
||||||
|
null,
|
||||||
|
syncServerUrl ?: "",
|
||||||
|
"YourRelayServerDomain.com", 0,
|
||||||
|
UIDialogs.Action("Cancel", {}),
|
||||||
|
UIDialogs.Action("Reset", {
|
||||||
|
syncServerUrl = null;
|
||||||
|
instance.save();
|
||||||
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
|
}, UIDialogs.ActionStyle.ACCENT),
|
||||||
|
UIDialogs.Action.withInput("Configure", {
|
||||||
|
syncServerUrl = it?.text
|
||||||
|
instance.save();
|
||||||
|
SettingsFragment.currentView?.reloadSettings();
|
||||||
|
UIDialogs.toast("Sync server changes require a restart");
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@FormField(R.string.info, FieldForm.GROUP, -1, 21)
|
||||||
var info = Info();
|
var info = Info();
|
||||||
@Serializable
|
@Serializable
|
||||||
class Info {
|
class Info {
|
||||||
@@ -909,4 +1233,4 @@ class Settings : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
package com.futo.platformplayer
|
package com.futo.platformplayer
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import androidx.work.Data
|
import androidx.work.Data
|
||||||
import androidx.work.OneTimeWorkRequestBuilder
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.caoccao.javet.values.primitive.V8ValueInteger
|
import com.caoccao.javet.values.primitive.V8ValueInteger
|
||||||
import com.caoccao.javet.values.primitive.V8ValueString
|
import com.caoccao.javet.values.primitive.V8ValueString
|
||||||
import com.futo.platformplayer.activities.DeveloperActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.activities.SettingsActivity
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
@@ -18,6 +18,8 @@ import com.futo.platformplayer.api.media.platforms.js.SourcePluginDescriptor
|
|||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.background.BackgroundWorker
|
import com.futo.platformplayer.background.BackgroundWorker
|
||||||
import com.futo.platformplayer.engine.V8Plugin
|
import com.futo.platformplayer.engine.V8Plugin
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.DeveloperFragment
|
||||||
|
import com.futo.platformplayer.fragment.mainactivity.main.SettingsFragment
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
|
||||||
import com.futo.platformplayer.states.StateAnnouncement
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
@@ -95,10 +97,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
fun subscriptionsCache5000() {
|
fun subscriptionsCache5000() {
|
||||||
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
Logger.i("SettingsDev", "Started caching 5000 sub items");
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Started caching 5000 sub items"
|
"Started caching 5000 sub items"
|
||||||
);
|
);
|
||||||
val button = DeveloperActivity.getActivity()?.getField("subscription_cache_button");
|
val button = DeveloperFragment.currentView?.getField("subscription_cache_button");
|
||||||
if(button is ButtonField)
|
if(button is ButtonField)
|
||||||
button.setButtonEnabled(false);
|
button.setButtonEnabled(false);
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
@@ -119,7 +121,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
val diff = System.currentTimeMillis() - lastToast;
|
val diff = System.currentTimeMillis() - lastToast;
|
||||||
lastToast = System.currentTimeMillis();
|
lastToast = System.currentTimeMillis();
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,7 +130,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"FINISHED Page: ${page}, Total: ${total}"
|
"FINISHED Page: ${page}, Total: ${total}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,10 +152,10 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
fun historyCache100() {
|
fun historyCache100() {
|
||||||
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
Logger.i("SettingsDev", "Started caching 100 history items (from home)");
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Started caching 100 history items (from home)"
|
"Started caching 100 history items (from home)"
|
||||||
);
|
);
|
||||||
val button = DeveloperActivity.getActivity()?.getField("history_cache_button");
|
val button = DeveloperFragment.currentView?.getField("history_cache_button");
|
||||||
if(button is ButtonField)
|
if(button is ButtonField)
|
||||||
button.setButtonEnabled(false);
|
button.setButtonEnabled(false);
|
||||||
StateApp.instance.scope.launch(Dispatchers.IO) {
|
StateApp.instance.scope.launch(Dispatchers.IO) {
|
||||||
@@ -184,7 +186,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
val diff = System.currentTimeMillis() - lastToast;
|
val diff = System.currentTimeMillis() - lastToast;
|
||||||
lastToast = System.currentTimeMillis();
|
lastToast = System.currentTimeMillis();
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
"Page: ${page}, Total: ${total}, Speed: ${diff}ms"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,7 +195,7 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(
|
UIDialogs.toast(
|
||||||
SettingsActivity.getActivity()!!,
|
StateApp.instance.activity!!,
|
||||||
"FINISHED Page: ${page}, Total: ${total}"
|
"FINISHED Page: ${page}, Total: ${total}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -233,21 +235,25 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
@FormField(R.string.test_background_worker, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
fun triggerBackgroundUpdate() {
|
fun triggerBackgroundUpdate() {
|
||||||
val act = SettingsActivity.getActivity()!!;
|
val act = StateApp.instance.activity!!;
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Starting test background worker");
|
try {
|
||||||
|
UIDialogs.toast(StateApp.instance.activity!!, "Starting test background worker");
|
||||||
|
|
||||||
val wm = WorkManager.getInstance(act);
|
val wm = WorkManager.getInstance(act);
|
||||||
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
val req = OneTimeWorkRequestBuilder<BackgroundWorker>()
|
||||||
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
.setInputData(Data.Builder().putBoolean("bypassMainCheck", true).build())
|
||||||
.build();
|
.build();
|
||||||
wm.enqueue(req);
|
wm.enqueue(req);
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
UIDialogs.showGeneralErrorDialog(act, "Failed to trigger background update", e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
@FormField(R.string.clear_channel_cache, FieldForm.BUTTON,
|
||||||
R.string.test_background_worker_description, 4)
|
R.string.test_background_worker_description, 4)
|
||||||
fun clearChannelContentCache() {
|
fun clearChannelContentCache() {
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Clearing cache");
|
UIDialogs.toast(StateApp.instance.activity!!, "Clearing cache");
|
||||||
StateCache.instance.clearToday();
|
StateCache.instance.clearToday();
|
||||||
UIDialogs.toast(SettingsActivity.getActivity()!!, "Cleared");
|
UIDialogs.toast(StateApp.instance.activity!!, "Cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -491,6 +497,13 @@ class SettingsDev : FragmentedStorageFileJson() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@FormField(R.string.test_playback, FieldForm.BUTTON,
|
||||||
|
R.string.test_playback, 1)
|
||||||
|
fun testPlayback(context: Context) {
|
||||||
|
context.startActivity(MainActivity.getActionIntent(context, "TEST_PLAYBACK"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import android.app.AlertDialog
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.text.Layout
|
||||||
|
import android.text.method.ScrollingMovementMethod
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -16,6 +19,7 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.activities.MainActivity
|
import com.futo.platformplayer.activities.MainActivity
|
||||||
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
import com.futo.platformplayer.api.media.models.comments.IPlatformComment
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
@@ -110,8 +114,8 @@ class UIDialogs {
|
|||||||
currentDialog.code,
|
currentDialog.code,
|
||||||
currentDialog.defaultCloseAction,
|
currentDialog.defaultCloseAction,
|
||||||
*currentDialog.actions.map {
|
*currentDialog.actions.map {
|
||||||
return@map Action(it.text, {
|
return@map Action.withInput(it.text, { str ->
|
||||||
it.action();
|
it.invokeAction(str);
|
||||||
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
multiShowDialog(context, dialogDescriptor.drop(1), finally);
|
||||||
}, it.style);
|
}, it.style);
|
||||||
}.toTypedArray());
|
}.toTypedArray());
|
||||||
@@ -162,27 +166,42 @@ class UIDialogs {
|
|||||||
dialog.show()
|
dialog.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticBackupDialog(context: Context, skipRestoreCheck: Boolean = false, onClosed: (()->Unit)? = null) {
|
fun showAutomaticBackupDialog(context: Context, onClosed: (() -> Unit)? = null) {
|
||||||
val dialogAction: ()->Unit = {
|
val dialogAction: () -> Unit = {
|
||||||
val dialog = AutomaticBackupDialog(context);
|
val dialog = AutomaticBackupDialog(context)
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog)
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog); onClosed?.invoke() };
|
dialog.setOnDismissListener {
|
||||||
dialog.show();
|
registerDialogClosed(dialog)
|
||||||
};
|
onClosed?.invoke()
|
||||||
if(StateBackup.hasAutomaticBackup() && !skipRestoreCheck)
|
}
|
||||||
UIDialogs.showDialog(context, R.drawable.ic_move_up, context.getString(R.string.an_old_backup_is_available), context.getString(R.string.would_you_like_to_restore_this_backup), null, 0,
|
dialog.show()
|
||||||
UIDialogs.Action(context.getString(R.string.cancel), {}), //To nothing
|
}
|
||||||
UIDialogs.Action(context.getString(R.string.override), {
|
|
||||||
dialogAction();
|
if (!Settings.instance.backup.autoBackupEnabled && StateBackup.hasAutomaticBackup()) {
|
||||||
|
UIDialogs.showDialog(
|
||||||
|
context,
|
||||||
|
R.drawable.ic_move_up,
|
||||||
|
context.getString(R.string.an_old_backup_is_available),
|
||||||
|
context.getString(R.string.would_you_like_to_restore_this_backup),
|
||||||
|
null,
|
||||||
|
0,
|
||||||
|
UIDialogs.Action(context.getString(R.string.cancel), {}),
|
||||||
|
UIDialogs.Action(context.getString(R.string.continue_anyway), {
|
||||||
|
dialogAction()
|
||||||
}, UIDialogs.ActionStyle.DANGEROUS),
|
}, UIDialogs.ActionStyle.DANGEROUS),
|
||||||
UIDialogs.Action(context.getString(R.string.restore), {
|
UIDialogs.Action(context.getString(R.string.restore), {
|
||||||
UIDialogs.showAutomaticRestoreDialog(context, StateApp.instance.scope);
|
val scope = (context as? androidx.lifecycle.LifecycleOwner)?.lifecycleScope
|
||||||
|
?: StateApp.instance.scopeOrNull
|
||||||
|
?: StateApp.instance.scope
|
||||||
|
|
||||||
|
UIDialogs.showAutomaticRestoreDialog(context, scope)
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
)
|
||||||
else {
|
} else {
|
||||||
dialogAction();
|
dialogAction()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
fun showAutomaticRestoreDialog(context: Context, scope: CoroutineScope) {
|
||||||
val dialog = AutomaticRestoreDialog(context, scope);
|
val dialog = AutomaticRestoreDialog(context, scope);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
@@ -197,50 +216,69 @@ class UIDialogs {
|
|||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
fun showDialog(context: Context, icon: Int, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action) {
|
return showDialog(context, icon, false, text, textDetails, code, defaultCloseAction, *actions);
|
||||||
|
}
|
||||||
|
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, defaultCloseAction: Int, vararg actions: Action): AlertDialog
|
||||||
|
= showDialog(context, icon, animated, text, textDetails, code, null, null, defaultCloseAction, *actions);
|
||||||
|
fun showDialog(context: Context, icon: Int, animated: Boolean, text: String, textDetails: String? = null, code: String? = null, input: String?, placeholder: String?, defaultCloseAction: Int, vararg actions: Action): AlertDialog {
|
||||||
val builder = AlertDialog.Builder(context);
|
val builder = AlertDialog.Builder(context);
|
||||||
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
val view = LayoutInflater.from(context).inflate(R.layout.dialog_multi_button, null);
|
||||||
builder.setView(view);
|
builder.setView(view);
|
||||||
|
builder.setCancelable(defaultCloseAction > -2);
|
||||||
val dialog = builder.create();
|
val dialog = builder.create();
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
|
||||||
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
view.findViewById<ImageView>(R.id.dialog_icon).apply {
|
||||||
this.setImageResource(icon);
|
this.setImageResource(icon);
|
||||||
|
if(animated)
|
||||||
|
this.drawable.assume<Animatable, Unit> { it.start() };
|
||||||
}
|
}
|
||||||
view.findViewById<TextView>(R.id.dialog_text).apply {
|
view.findViewById<TextView>(R.id.dialog_text).apply {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
};
|
};
|
||||||
view.findViewById<TextView>(R.id.dialog_text_details).apply {
|
view.findViewById<TextView>(R.id.dialog_text_details).apply {
|
||||||
if(textDetails == null)
|
if (textDetails == null)
|
||||||
this.visibility = View.GONE;
|
|
||||||
else
|
|
||||||
this.text = textDetails;
|
|
||||||
};
|
|
||||||
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
|
||||||
if(code == null)
|
|
||||||
this.visibility = View.GONE;
|
this.visibility = View.GONE;
|
||||||
else {
|
else {
|
||||||
this.text = code;
|
this.text = textDetails;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var inputView = view.findViewById<TextView>(R.id.dialog_text_input);
|
||||||
|
inputView.apply {
|
||||||
|
if (input == null && placeholder == null) this.visibility = View.GONE;
|
||||||
|
else {
|
||||||
|
this.text = input ?: "";
|
||||||
|
this.hint = placeholder ?: "";
|
||||||
this.visibility = View.VISIBLE;
|
this.visibility = View.VISIBLE;
|
||||||
|
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||||
|
}
|
||||||
|
};
|
||||||
|
view.findViewById<TextView>(R.id.dialog_text_code).apply {
|
||||||
|
if (code == null) this.visibility = View.GONE;
|
||||||
|
else {
|
||||||
|
this.text = code;
|
||||||
|
this.movementMethod = ScrollingMovementMethod.getInstance();
|
||||||
|
this.visibility = View.VISIBLE;
|
||||||
|
this.textAlignment = View.TEXT_ALIGNMENT_VIEW_START
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
view.findViewById<LinearLayout>(R.id.dialog_buttons).apply {
|
||||||
|
val center = actions.any { it?.center == true };
|
||||||
val buttons = actions.map<Action, TextView> { act ->
|
val buttons = actions.map<Action, TextView> { act ->
|
||||||
val buttonView = TextView(context);
|
val buttonView = TextView(context);
|
||||||
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
val dp10 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10f, resources.displayMetrics).toInt();
|
||||||
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
val dp28 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 28f, resources.displayMetrics).toInt();
|
||||||
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
val dp14 = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14.0f, resources.displayMetrics).toInt();
|
||||||
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
buttonView.layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply {
|
||||||
if(actions.size > 1)
|
this.marginStart = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||||
this.marginEnd = if(actions.size > 2) dp14 else dp28;
|
this.marginEnd = if(actions.size >= 2) dp14 / 2 else dp28 / 2;
|
||||||
};
|
};
|
||||||
buttonView.setTextColor(Color.WHITE);
|
buttonView.setTextColor(Color.WHITE);
|
||||||
buttonView.textSize = 14f;
|
buttonView.textSize = 14f;
|
||||||
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
buttonView.typeface = resources.getFont(R.font.inter_regular);
|
||||||
buttonView.text = act.text;
|
buttonView.text = act.text;
|
||||||
buttonView.setOnClickListener { act.action(); dialog.dismiss(); };
|
buttonView.setOnClickListener { act.invokeAction(DialogResult(inputView?.text?.toString())); dialog.dismiss(); };
|
||||||
when(act.style) {
|
when(act.style) {
|
||||||
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
ActionStyle.PRIMARY -> buttonView.setBackgroundResource(R.drawable.background_button_primary);
|
||||||
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
ActionStyle.ACCENT -> buttonView.setBackgroundResource(R.drawable.background_button_accent);
|
||||||
@@ -256,7 +294,7 @@ class UIDialogs {
|
|||||||
|
|
||||||
return@map buttonView;
|
return@map buttonView;
|
||||||
};
|
};
|
||||||
if(actions.size <= 1)
|
if(actions.size <= 1 || center)
|
||||||
this.gravity = Gravity.CENTER;
|
this.gravity = Gravity.CENTER;
|
||||||
else
|
else
|
||||||
this.gravity = Gravity.END;
|
this.gravity = Gravity.END;
|
||||||
@@ -265,12 +303,13 @@ class UIDialogs {
|
|||||||
};
|
};
|
||||||
dialog.setOnCancelListener {
|
dialog.setOnCancelListener {
|
||||||
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
if(defaultCloseAction >= 0 && defaultCloseAction < actions.size)
|
||||||
actions[defaultCloseAction].action();
|
actions[defaultCloseAction].invokeAction(DialogResult(inputView?.text?.toString()));
|
||||||
}
|
}
|
||||||
dialog.setOnDismissListener {
|
dialog.setOnDismissListener {
|
||||||
registerDialogClosed(dialog);
|
registerDialogClosed(dialog);
|
||||||
}
|
}
|
||||||
dialog.show();
|
dialog.show();
|
||||||
|
return dialog;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
fun showGeneralErrorDialog(context: Context, msg: String, ex: Throwable? = null, button: String = "Ok", onOk: (()->Unit)? = null) {
|
||||||
@@ -308,7 +347,11 @@ class UIDialogs {
|
|||||||
closeAction?.invoke()
|
closeAction?.invoke()
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
UIDialogs.Action(context.getString(R.string.retry), {
|
UIDialogs.Action(context.getString(R.string.retry), {
|
||||||
retryAction?.invoke();
|
try {
|
||||||
|
retryAction?.invoke();
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||||
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
);
|
||||||
else
|
else
|
||||||
@@ -322,7 +365,11 @@ class UIDialogs {
|
|||||||
closeAction?.invoke()
|
closeAction?.invoke()
|
||||||
}, UIDialogs.ActionStyle.NONE),
|
}, UIDialogs.ActionStyle.NONE),
|
||||||
UIDialogs.Action(context.getString(R.string.retry), {
|
UIDialogs.Action(context.getString(R.string.retry), {
|
||||||
retryAction?.invoke();
|
try {
|
||||||
|
retryAction?.invoke();
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Unhandled exception retrying", e)
|
||||||
|
}
|
||||||
}, UIDialogs.ActionStyle.PRIMARY)
|
}, UIDialogs.ActionStyle.PRIMARY)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -339,10 +386,19 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null) {
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null): AlertDialog {
|
||||||
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction)
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, cancelButtonAction, confirmButtonAction).apply {
|
||||||
|
setOnDismissListener { dismissAction?.invoke() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showConfirmationDialog(context: Context, text: String, action: () -> Unit, cancelAction: (() -> Unit)? = null, dismissAction: (() -> Unit)? = null, doNotAskAgainAction: (() -> Unit)? = null): AlertDialog {
|
||||||
|
val confirmButtonAction = Action(context.getString(R.string.confirm), action, ActionStyle.PRIMARY)
|
||||||
|
val cancelButtonAction = Action(context.getString(R.string.cancel), cancelAction ?: {}, ActionStyle.ACCENT)
|
||||||
|
val doNotAskAgain = Action(context.getString(R.string.do_not_ask_again), doNotAskAgainAction ?: {}, ActionStyle.NONE)
|
||||||
|
return showDialog(context, R.drawable.ic_error, text, null, null, 0, doNotAskAgain, cancelButtonAction, confirmButtonAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
fun showUpdateAvailableDialog(context: Context, lastVersion: Int, hideExceptionButtons: Boolean = false) {
|
||||||
@@ -357,21 +413,14 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showChangelogDialog(context: Context, lastVersion: Int) {
|
fun showChangelogDialog(context: Context, lastVersion: Int, changelogs: Map<Int, String>? = null) {
|
||||||
val dialog = ChangelogDialog(context);
|
val dialog = ChangelogDialog(context, changelogs);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
dialog.setMaxVersion(lastVersion);
|
dialog.setMaxVersion(lastVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showInstallDownloadedUpdateDialog(context: Context, apkFile: File) {
|
|
||||||
val dialog = AutoUpdateDialog(context);
|
|
||||||
registerDialogOpened(dialog);
|
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
|
||||||
dialog.showPredownloaded(apkFile);
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
fun showMigrateDialog(context: Context, store: ManagedStore<*>, onConcluded: ()->Unit) {
|
||||||
if(!store.hasMissingReconstructions())
|
if(!store.hasMissingReconstructions())
|
||||||
onConcluded();
|
onConcluded();
|
||||||
@@ -398,7 +447,7 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun showCastingDialog(context: Context) {
|
fun showCastingDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val d = StateCasting.instance.activeDevice;
|
val d = StateCasting.instance.activeDevice;
|
||||||
if (d != null) {
|
if (d != null) {
|
||||||
val dialog = ConnectedCastingDialog(context);
|
val dialog = ConnectedCastingDialog(context);
|
||||||
@@ -406,6 +455,7 @@ class UIDialogs {
|
|||||||
dialog.setOwnerActivity(context)
|
dialog.setOwnerActivity(context)
|
||||||
}
|
}
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
} else {
|
} else {
|
||||||
@@ -418,21 +468,24 @@ class UIDialogs {
|
|||||||
if (c is Activity) {
|
if (c is Activity) {
|
||||||
dialog.setOwnerActivity(c);
|
dialog.setOwnerActivity(c);
|
||||||
}
|
}
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCastingTutorialDialog(context: Context) {
|
fun showCastingTutorialDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val dialog = CastingHelpDialog(context);
|
val dialog = CastingHelpDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showCastingAddDialog(context: Context) {
|
fun showCastingAddDialog(context: Context, ownerActivity: Activity? = null) {
|
||||||
val dialog = CastingAddDialog(context);
|
val dialog = CastingAddDialog(context);
|
||||||
registerDialogOpened(dialog);
|
registerDialogOpened(dialog);
|
||||||
|
ownerActivity?.let { dialog.setOwnerActivity(it) }
|
||||||
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
dialog.setOnDismissListener { registerDialogClosed(dialog) };
|
||||||
dialog.show();
|
dialog.show();
|
||||||
}
|
}
|
||||||
@@ -505,15 +558,36 @@ class UIDialogs {
|
|||||||
}
|
}
|
||||||
class Action {
|
class Action {
|
||||||
val text: String;
|
val text: String;
|
||||||
val action: ()->Unit;
|
val action: ((DialogResult?)->Unit);
|
||||||
val style: ActionStyle;
|
val style: ActionStyle;
|
||||||
|
var center: Boolean;
|
||||||
|
|
||||||
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE) {
|
constructor(text: String, action: ()->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||||
|
this.text = text;
|
||||||
|
this.action = { action() };
|
||||||
|
this.style = style;
|
||||||
|
this.center = center;
|
||||||
|
}
|
||||||
|
protected constructor(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
this.action = action;
|
this.action = action;
|
||||||
this.style = style;
|
this.style = style;
|
||||||
|
this.center = center;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invokeAction(input: DialogResult? = null) {
|
||||||
|
this.action(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun withInput(text: String, action: (DialogResult?)->Unit, style: ActionStyle = ActionStyle.NONE, center: Boolean = false): Action {
|
||||||
|
return Action(text, action, style, center);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
class DialogResult(
|
||||||
|
val text: String?
|
||||||
|
);
|
||||||
enum class ActionStyle {
|
enum class ActionStyle {
|
||||||
NONE,
|
NONE,
|
||||||
PRIMARY,
|
PRIMARY,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class UpdateActionReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
UpdateNotificationManager.ACTION_DOWNLOAD_CANCEL -> handleDownloadCancel(context, intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadCancel(context: Context, intent: Intent) {
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_CANCEL, true)
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(context, cancelIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).cancel(UpdateNotificationManager.NOTIF_ID_DOWNLOADING)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class UpdateCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
if (!Settings.instance.autoUpdate.isAutoUpdateEnabled()) {
|
||||||
|
Logger.i(TAG, "Auto-update disabled, skipping worker run")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val client = ManagedHttpClient()
|
||||||
|
val latestVersion = StateUpdate.Companion.instance.downloadVersionCode(client)
|
||||||
|
|
||||||
|
if (latestVersion == null) {
|
||||||
|
Logger.w(TAG, "Failed to fetch latest version in worker")
|
||||||
|
return@withContext Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
Logger.i(TAG, "Worker check: current=$currentVersion, latest=$latestVersion")
|
||||||
|
|
||||||
|
if (latestVersion <= currentVersion) {
|
||||||
|
return@withContext Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
StateUpdate.Companion.instance.setUiAvailable(latestVersion)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val serviceIntent = Intent(applicationContext, UpdateDownloadService::class.java).apply {
|
||||||
|
putExtra(UpdateDownloadService.EXTRA_VERSION, latestVersion)
|
||||||
|
}
|
||||||
|
ContextCompat.startForegroundService(applicationContext, serviceIntent)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to start UpdateDownloadService", t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(latestVersion, t.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception in UpdateCheckWorker", t)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateCheckWorker"
|
||||||
|
const val UNIQUE_WORK_NAME = "updateCheck"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
import com.futo.platformplayer.states.AnnouncementType
|
||||||
|
import com.futo.platformplayer.states.SessionAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateAnnouncement
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class UpdateDownloadService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "UpdateDownloadService"
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_CANCEL = "cancel"
|
||||||
|
private const val MAX_RETRIES = 5
|
||||||
|
private const val INITIAL_BACKOFF_MS = 5_000L
|
||||||
|
private const val BUFFER_SIZE = 8 * 1024
|
||||||
|
private const val MIN_PROGRESS_UPDATE_INTERVAL_MS = 500L
|
||||||
|
}
|
||||||
|
|
||||||
|
private val job = SupervisorJob()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + job)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var isDownloading: Boolean = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cancelRequested: Boolean = false
|
||||||
|
|
||||||
|
private var lastProgressUpdateElapsedMs: Long = 0L
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent == null) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intent.getBooleanExtra(EXTRA_CANCEL, false)) {
|
||||||
|
cancelRequested = true
|
||||||
|
Logger.i(TAG, "Download cancel requested")
|
||||||
|
StateUpdate.Companion.instance.clearUi()
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(EXTRA_VERSION, 0)
|
||||||
|
if (version == 0) {
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDownloading) {
|
||||||
|
Logger.i(TAG, "Download already in progress, ignoring new start")
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
isDownloading = true
|
||||||
|
cancelRequested = false
|
||||||
|
|
||||||
|
StateUpdate.Companion.instance.setUiDownloading(version, 0, indeterminate = true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(this).cancel(UpdateNotificationManager.NOTIF_ID_READY)
|
||||||
|
|
||||||
|
val notification = UpdateNotificationManager.buildDownloadProgressNotification(this, version, 0, true)
|
||||||
|
startForeground(UpdateNotificationManager.NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
downloadApk(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun throttledUpdateDownloadProgress(version: Int, progress: Int, indeterminate: Boolean, onProgress: ((Int) -> Unit)? = null) {
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val force = progress == 100 && !indeterminate
|
||||||
|
|
||||||
|
if (force || now - lastProgressUpdateElapsedMs >= MIN_PROGRESS_UPDATE_INTERVAL_MS) {
|
||||||
|
lastProgressUpdateElapsedMs = now
|
||||||
|
UpdateNotificationManager.updateDownloadProgress(this, version, progress, indeterminate);
|
||||||
|
StateUpdate.Companion.instance.setUiDownloading(version, progress, indeterminate)
|
||||||
|
|
||||||
|
if(onProgress != null)
|
||||||
|
onProgress.invoke(progress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadApk(version: Int) {
|
||||||
|
val apkFile = StateUpdate.getApkFile(this, version)
|
||||||
|
val partialFile = StateUpdate.getPartialApkFile(this, version)
|
||||||
|
|
||||||
|
var announcement: SessionAnnouncement? = null;
|
||||||
|
try {
|
||||||
|
if (apkFile.exists() && apkFile.length() > 0L) {
|
||||||
|
Logger.i(TAG, "APK already downloaded at ${apkFile.absolutePath}")
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
announcement = StateAnnouncement.instance.registerLoading("Downloading new version [${version}]", "New version is being downloaded..",
|
||||||
|
ImageVariable.fromResource(R.drawable.foreground));
|
||||||
|
}
|
||||||
|
catch(ex: Exception){
|
||||||
|
Logger.e(TAG, "Failed to set progress announcement", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
var backoffMs = INITIAL_BACKOFF_MS
|
||||||
|
|
||||||
|
for (attempt in 0 until MAX_RETRIES) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled before attempt ${attempt + 1}")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
performDownload(StateUpdate.getApkUrl(version), partialFile, version, {
|
||||||
|
try {
|
||||||
|
if (announcement != null)
|
||||||
|
announcement?.setProgress(it);
|
||||||
|
}
|
||||||
|
catch(ex: Throwable) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cancelRequested) {
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
apkFile.delete()
|
||||||
|
}
|
||||||
|
if (!partialFile.renameTo(apkFile)) {
|
||||||
|
throw IllegalStateException("Failed to rename partial APK file")
|
||||||
|
}
|
||||||
|
onDownloadComplete(version, apkFile)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
if (cancelRequested) {
|
||||||
|
Logger.i(TAG, "Download cancelled by user", t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == MAX_RETRIES - 1) {
|
||||||
|
Logger.e(TAG, "Download failed after ${attempt + 1} attempts", t)
|
||||||
|
UpdateNotificationManager.showDownloadFailedNotification(this, version, t)
|
||||||
|
StateUpdate.Companion.instance.setUiFailed(version, t.message)
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Download attempt ${attempt + 1} failed, retrying in ${backoffMs / 1000}s", t)
|
||||||
|
delay(backoffMs)
|
||||||
|
backoffMs *= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (announcement != null) {
|
||||||
|
StateAnnouncement.instance.closeAnnouncement(announcement.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch(ex: Throwable){}
|
||||||
|
isDownloading = false
|
||||||
|
cancelRequested = false
|
||||||
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performDownload(url: String, partialFile: File, version: Int, onProgress: ((Int)->Unit)? = null) {
|
||||||
|
var startOffset = if (partialFile.exists()) partialFile.length() else 0L
|
||||||
|
Logger.i(TAG, "Starting download. url=$url, existingBytes=$startOffset")
|
||||||
|
|
||||||
|
var connection: HttpURLConnection? = null
|
||||||
|
try {
|
||||||
|
connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
if (startOffset > 0L) {
|
||||||
|
setRequestProperty("Range", "bytes=$startOffset-")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.connect()
|
||||||
|
val responseCode = connection.responseCode
|
||||||
|
|
||||||
|
if (responseCode == HttpURLConnection.HTTP_OK && startOffset > 0L) {
|
||||||
|
Logger.w(TAG, "Server ignored Range header, restarting download from scratch")
|
||||||
|
partialFile.delete()
|
||||||
|
startOffset = 0L
|
||||||
|
} else if (responseCode != HttpURLConnection.HTTP_OK &&
|
||||||
|
responseCode != HttpURLConnection.HTTP_PARTIAL) {
|
||||||
|
throw IllegalStateException("Unexpected HTTP response code $responseCode")
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentLength = connection.contentLengthLong
|
||||||
|
val totalBytes = if (contentLength > 0L) startOffset + contentLength else -1L
|
||||||
|
|
||||||
|
val buffer = ByteArray(BUFFER_SIZE)
|
||||||
|
var downloaded = 0L
|
||||||
|
var lastProgress = -1
|
||||||
|
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
FileOutputStream(partialFile, startOffset > 0L).use { output ->
|
||||||
|
while (!cancelRequested) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read == -1) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
downloaded += read
|
||||||
|
|
||||||
|
if (totalBytes > 0L) {
|
||||||
|
val progress = (((startOffset + downloaded) * 100L) / totalBytes).toInt()
|
||||||
|
if (progress != lastProgress) {
|
||||||
|
lastProgress = progress
|
||||||
|
val safeProgress = when {
|
||||||
|
progress < 0 -> 0
|
||||||
|
progress > 100 -> 100
|
||||||
|
else -> progress
|
||||||
|
}
|
||||||
|
throttledUpdateDownloadProgress(version, safeProgress, indeterminate = false, onProgress)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throttledUpdateDownloadProgress(version, progress = 0, indeterminate = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cancelRequested && totalBytes > 0L) {
|
||||||
|
val finalProgress = 100
|
||||||
|
throttledUpdateDownloadProgress(version, finalProgress, indeterminate = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelRequested) {
|
||||||
|
throw CancellationException("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalBytes > 0L && startOffset + downloaded < totalBytes) {
|
||||||
|
throw IllegalStateException("Download incomplete: expected=$totalBytes, got=${startOffset + downloaded}")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadComplete(version: Int, apkFile: File) {
|
||||||
|
Logger.i(TAG, "Download complete for version=$version, file=${apkFile.absolutePath}")
|
||||||
|
UpdateNotificationManager.showDownloadCompleteNotification(this, version, apkFile)
|
||||||
|
StateUpdate.Companion.instance.setUiReady(version, apkFile)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val ctx = applicationContext
|
||||||
|
StateAnnouncement.instance.registerAnnouncement("install-update-apk", "Grayjay v${version} is ready!", "You can now install the new Grayjay version.", AnnouncementType.SESSION, OffsetDateTime.now(), "update", "Install") {
|
||||||
|
UpdateNotificationManager.cancelAll(ctx)
|
||||||
|
UpdateInstaller.startInstall(ctx, version, apkFile)
|
||||||
|
}
|
||||||
|
} catch (ex: Throwable) {
|
||||||
|
Logger.w(TAG, "Failed to register install announcement", ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.graphics.drawable.Animatable
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.View
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.receivers.InstallReceiver
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.GlobalScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.InputStream
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.futo.platformplayer.dialogs.AutoUpdateDialog
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateUpdate
|
||||||
|
|
||||||
|
object UpdateInstaller {
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
@SuppressLint("RequestInstallPackagesPolicy")
|
||||||
|
fun startInstall(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w(TAG, "APK file does not exist: ${apkFile.absolutePath}")
|
||||||
|
UIDialogs.toast(context, "Update file missing")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "APK file does not exist.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BuildConfig.IS_PLAYSTORE_BUILD) {
|
||||||
|
UIDialogs.toast(context, "Updates are managed by the Play Store")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Updates are managed by the Play Store.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val pm = context.packageManager
|
||||||
|
if (!pm.canRequestPackageInstalls()) {
|
||||||
|
UIDialogs.toast(context, "Allow this app to install updates, then try again")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, "Install update permission was missing.")
|
||||||
|
|
||||||
|
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
||||||
|
data = "package:${context.packageName}".toUri()
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to check unknown sources permission", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
|
var inputStream: InputStream? = null
|
||||||
|
var session: PackageInstaller.Session? = null
|
||||||
|
try {
|
||||||
|
val dataLength = apkFile.length()
|
||||||
|
val usable = try { context.filesDir.usableSpace } catch (_: Throwable) { -1L }
|
||||||
|
if (usable in 0 until dataLength) {
|
||||||
|
val msg = "Not enough storage to install update. Need ${dataLength / 1_048_576L}MB, have ${usable / 1_048_576L}MB free."
|
||||||
|
Logger.w(TAG, msg)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, msg)
|
||||||
|
}
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, msg)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
session = packageInstaller.openSession(sessionId)
|
||||||
|
|
||||||
|
inputStream = apkFile.inputStream()
|
||||||
|
|
||||||
|
session.openWrite("package", 0, dataLength).use { sessionStream ->
|
||||||
|
inputStream.copyToOutputStream(dataLength, sessionStream) { _ -> }
|
||||||
|
session.fsync(sessionStream)
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(context, InstallReceiver::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkFile.absolutePath)
|
||||||
|
}
|
||||||
|
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT)
|
||||||
|
val statusReceiver = pendingIntent.intentSender
|
||||||
|
|
||||||
|
InstallReceiver.onReceiveResult.subscribe(this) { message ->
|
||||||
|
InstallReceiver.onReceiveResult.clear();
|
||||||
|
onReceiveResult(context, version, apkFile, message);
|
||||||
|
};
|
||||||
|
Logger.i(TAG, "Committing install session for ${apkFile.absolutePath}")
|
||||||
|
session.commit(statusReceiver)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.w(TAG, "Exception while installing update", e)
|
||||||
|
session?.abandon()
|
||||||
|
|
||||||
|
val raw = e.message ?: ""
|
||||||
|
val friendly = if (raw.contains("Failed to allocate") || raw.contains("allocatable") || raw.contains("ENOSPC", ignoreCase = true)) {
|
||||||
|
"Not enough storage to install update. Free up some space and try again."
|
||||||
|
} else {
|
||||||
|
"Failed to install update: $raw"
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
UIDialogs.toast(context, friendly)
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, friendly)
|
||||||
|
} finally {
|
||||||
|
session?.close()
|
||||||
|
inputStream?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onReceiveResult(context: Context, version: Int, apkFile: File, result: String?) {
|
||||||
|
try {
|
||||||
|
InstallReceiver.onReceiveResult.remove(this)
|
||||||
|
|
||||||
|
if (result.isNullOrEmpty()) {
|
||||||
|
Logger.i(TAG, "Update install finished successfully")
|
||||||
|
UpdateNotificationManager.showInstallSucceededNotification(context, version)
|
||||||
|
StateUpdate.instance.clearUi()
|
||||||
|
} else {
|
||||||
|
Logger.w(TAG, "Update install failed: $result")
|
||||||
|
UpdateNotificationManager.showInstallFailedNotification(context, version, apkFile, result)
|
||||||
|
UIDialogs.showGeneralErrorDialog(context, "Install failed due to:\n$result")
|
||||||
|
StateUpdate.instance.setUiReady(version, apkFile)
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to handle install result", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.futo.platformplayer
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PendingIntent.FLAG_MUTABLE
|
||||||
|
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
import android.app.PendingIntent.getBroadcast
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.futo.platformplayer.activities.InstallUpdateActivity
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
object UpdateNotificationManager {
|
||||||
|
private const val CHANNEL_ID = "app_updates"
|
||||||
|
private const val CHANNEL_NAME = "App updates"
|
||||||
|
private const val CHANNEL_DESCRIPTION = "Notifications about new app versions"
|
||||||
|
|
||||||
|
const val ACTION_DOWNLOAD_CANCEL = "com.futo.platformplayer.UPDATE_CANCEL"
|
||||||
|
const val ACTION_INSTALL_NOW = "com.futo.platformplayer.UPDATE_INSTALL"
|
||||||
|
private const val REQUEST_CODE_INSTALL = 1001
|
||||||
|
|
||||||
|
const val EXTRA_VERSION = "version"
|
||||||
|
const val EXTRA_APK_PATH = "apk_path"
|
||||||
|
|
||||||
|
const val NOTIF_ID_DOWNLOADING = 2002
|
||||||
|
const val NOTIF_ID_READY = 2003
|
||||||
|
const val NOTIF_ID_INSTALL_FAILED = 2004
|
||||||
|
const val NOTIF_ID_INSTALL_SUCCEEDED = 2005
|
||||||
|
|
||||||
|
fun ensureChannel(context: Context) {
|
||||||
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) == null) {
|
||||||
|
val channel = NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT).apply {
|
||||||
|
description = CHANNEL_DESCRIPTION
|
||||||
|
enableVibration(false)
|
||||||
|
enableLights(false)
|
||||||
|
setSound(null, null)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallSucceededNotification(context: Context, version: Int) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val launchIntent = context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchPendingIntent = launchIntent?.let {
|
||||||
|
PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, it, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update installed")
|
||||||
|
.setContentText("Version $version installed. Tap to open.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
if (launchPendingIntent != null) {
|
||||||
|
builder.setContentIntent(launchPendingIntent)
|
||||||
|
builder.addAction(0, "Open app", launchPendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_SUCCEEDED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildDownloadProgressNotification(context: Context, version: Int, progress: Int, indeterminate: Boolean): Notification {
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val cancelIntent = Intent(context, UpdateActionReceiver::class.java).apply {
|
||||||
|
action = ACTION_DOWNLOAD_CANCEL
|
||||||
|
putExtra(EXTRA_VERSION, version)
|
||||||
|
}
|
||||||
|
val cancelPendingIntent = getBroadcast(
|
||||||
|
context,
|
||||||
|
3,
|
||||||
|
cancelIntent,
|
||||||
|
FLAG_MUTABLE or FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Downloading update")
|
||||||
|
.setContentText("Downloading version $version")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Cancel", cancelPendingIntent)
|
||||||
|
|
||||||
|
if (indeterminate) {
|
||||||
|
builder.setProgress(0, 0, true)
|
||||||
|
} else {
|
||||||
|
builder.setProgress(100, progress, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDownloadProgress(context: Context, version: Int, progress: Int, indeterminate: Boolean) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val notification = buildDownloadProgressNotification(context, version, progress, indeterminate)
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_DOWNLOADING, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadCompleteNotification(context: Context, version: Int, apkFile: File) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Update downloaded")
|
||||||
|
.setContentText("Tap to install version $version.")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.addAction(0, "Install", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun showDownloadFailedNotification(context: Context, version: Int, error: Throwable?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to download update")
|
||||||
|
.setContentText(error?.message ?: "Unknown error")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_READY, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showInstallFailedNotification(context: Context, version: Int, apkFile: File, error: String?) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
return
|
||||||
|
|
||||||
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val installIntent = InstallUpdateActivity.createIntent(context, version, apkFile.absolutePath)
|
||||||
|
val installPendingIntent = PendingIntent.getActivity(context, REQUEST_CODE_INSTALL, installIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.foreground)
|
||||||
|
.setContentTitle("Failed to install update")
|
||||||
|
.setContentText(if (error != null && error.isNotBlank()) "$error Tap to try again." else "Tap to try again.")
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setContentIntent(installPendingIntent)
|
||||||
|
.addAction(0, "Install again", installPendingIntent)
|
||||||
|
|
||||||
|
NotificationManagerCompat.from(context).notify(NOTIF_ID_INSTALL_FAILED, builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll(context: Context) {
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_DOWNLOADING)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_READY)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_FAILED)
|
||||||
|
NotificationManagerCompat.from(context).cancel(NOTIF_ID_INSTALL_SUCCEEDED)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,13 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.icu.util.Output
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.OperationCanceledException
|
import android.os.OperationCanceledException
|
||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsetsController
|
import android.view.WindowInsetsController
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
@@ -25,12 +24,27 @@ import com.futo.platformplayer.engine.V8Plugin
|
|||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.models.PlatformVideoWithTime
|
import com.futo.platformplayer.models.PlatformVideoWithTime
|
||||||
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
import com.futo.platformplayer.others.PlatformLinkMovementMethod
|
||||||
import java.io.File
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InterfaceAddress
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.SocketException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import java.time.OffsetDateTime
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.concurrent.ThreadLocalRandom
|
import java.util.concurrent.ThreadLocalRandom
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import com.bumptech.glide.RequestBuilder
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
|
||||||
|
|
||||||
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
private val _allowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
|
||||||
fun getRandomString(sizeOfRandomString: Int): String {
|
fun getRandomString(sizeOfRandomString: Int): String {
|
||||||
@@ -62,7 +76,14 @@ fun warnIfMainThread(context: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun ensureNotMainThread() {
|
fun ensureNotMainThread() {
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
val isMainLooper = try {
|
||||||
|
Looper.myLooper() == Looper.getMainLooper()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
//Ignore, for unit tests where its not mocked
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMainLooper) {
|
||||||
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
Logger.e("Utility", "Throwing exception because a function that should not be called on main thread, is called on main thread")
|
||||||
throw IllegalStateException("Cannot run on main thread")
|
throw IllegalStateException("Cannot run on main thread")
|
||||||
}
|
}
|
||||||
@@ -81,7 +102,7 @@ fun String.isHexColor(): Boolean {
|
|||||||
|
|
||||||
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
fun IPlatformClient.fromPool(pool: PlatformMultiClientPool) = pool.getClientPooled(this);
|
||||||
|
|
||||||
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
fun IPlatformVideo.withTimestamp(sec: Long) = PlatformVideoWithTime(this, sec);
|
||||||
|
|
||||||
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
fun DocumentFile.getInputStream(context: Context) = context.contentResolver.openInputStream(this.uri);
|
||||||
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
fun DocumentFile.getOutputStream(context: Context) = context.contentResolver.openOutputStream(this.uri);
|
||||||
@@ -94,23 +115,6 @@ fun DocumentFile.writeBytes(context: Context, byteArray: ByteArray) = context.co
|
|||||||
it.flush();
|
it.flush();
|
||||||
};
|
};
|
||||||
|
|
||||||
fun loadBitmap(url: String): Bitmap {
|
|
||||||
try {
|
|
||||||
val client = ManagedHttpClient();
|
|
||||||
val response = client.get(url);
|
|
||||||
if (response.isOk && response.body != null) {
|
|
||||||
val bitmapStream = response.body.byteStream();
|
|
||||||
val bitmap = BitmapFactory.decodeStream(bitmapStream);
|
|
||||||
return bitmap;
|
|
||||||
} else {
|
|
||||||
throw Exception("Failed to find data at URL.");
|
|
||||||
}
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
Logger.w("Utility", "Exception thrown while downloading bitmap.", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
fun TextView.setPlatformPlayerLinkMovementMethod(context: Context) {
|
||||||
this.movementMethod = PlatformLinkMovementMethod(context);
|
this.movementMethod = PlatformLinkMovementMethod(context);
|
||||||
}
|
}
|
||||||
@@ -229,4 +233,220 @@ fun String.decodeUnicode(): String {
|
|||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
return sb.toString()
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> smartMerge(targetArr: List<T>, toMerge: List<T>) : List<T>{
|
||||||
|
val missingToMerge = toMerge.filter { !targetArr.contains(it) }.toList();
|
||||||
|
val newArrResult = targetArr.toMutableList();
|
||||||
|
|
||||||
|
for(missing in missingToMerge) {
|
||||||
|
val newIndex = findNewIndex(toMerge, newArrResult, missing);
|
||||||
|
newArrResult.add(newIndex, missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newArrResult;
|
||||||
|
}
|
||||||
|
fun <T> findNewIndex(originalArr: List<T>, newArr: List<T>, item: T): Int{
|
||||||
|
var originalIndex = originalArr.indexOf(item);
|
||||||
|
var newIndex = -1;
|
||||||
|
|
||||||
|
for(i in originalIndex-1 downTo 0) {
|
||||||
|
val previousItem = originalArr[i];
|
||||||
|
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||||
|
if(indexInNewArr >= 0) {
|
||||||
|
newIndex = indexInNewArr + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(newIndex < 0) {
|
||||||
|
for(i in originalIndex+1 until originalArr.size) {
|
||||||
|
val previousItem = originalArr[i];
|
||||||
|
val indexInNewArr = newArr.indexOfFirst { it == previousItem };
|
||||||
|
if(indexInNewArr >= 0) {
|
||||||
|
newIndex = indexInNewArr - 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(newIndex < 0)
|
||||||
|
return newArr.size;
|
||||||
|
else
|
||||||
|
return newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteBuffer.toUtf8String(): String {
|
||||||
|
val remainingBytes = ByteArray(remaining())
|
||||||
|
get(remainingBytes)
|
||||||
|
return String(remainingBytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateReadablePassword(length: Int): String {
|
||||||
|
val validChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
||||||
|
val secureRandom = SecureRandom()
|
||||||
|
val randomBytes = ByteArray(length)
|
||||||
|
secureRandom.nextBytes(randomBytes)
|
||||||
|
val sb = StringBuilder(length)
|
||||||
|
for (byte in randomBytes) {
|
||||||
|
val index = (byte.toInt() and 0xFF) % validChars.length
|
||||||
|
sb.append(validChars[index])
|
||||||
|
}
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.toGzip(): ByteArray {
|
||||||
|
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
|
val gzipTimeStart = OffsetDateTime.now();
|
||||||
|
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(outputStream).use { gzip ->
|
||||||
|
gzip.write(this)
|
||||||
|
}
|
||||||
|
val result = outputStream.toByteArray();
|
||||||
|
Logger.i("Utility", "Gzip compression time: ${gzipTimeStart.getNowDiffMiliseconds()}ms");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ByteArray.fromGzip(): ByteArray {
|
||||||
|
if (this == null || this.isEmpty()) return ByteArray(0)
|
||||||
|
|
||||||
|
val inputStream = ByteArrayInputStream(this)
|
||||||
|
val outputStream = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
GZIPInputStream(inputStream).use { gzip ->
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var bytesRead: Int
|
||||||
|
while (gzip.read(buffer).also { bytesRead = it } != -1) {
|
||||||
|
outputStream.write(buffer, 0, bytesRead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outputStream.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findCandidateAddresses(): List<InetAddress> {
|
||||||
|
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||||
|
.toList()
|
||||||
|
.asSequence()
|
||||||
|
.filter(::isUsableInterface)
|
||||||
|
.flatMap { nif ->
|
||||||
|
nif.interfaceAddresses
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { ia ->
|
||||||
|
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||||
|
nif to ia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.sortedWith(
|
||||||
|
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||||
|
{ addressScore(it.second.address) },
|
||||||
|
{ interfaceScore(it.first) },
|
||||||
|
{ -it.second.networkPrefixLength.toInt() },
|
||||||
|
{ -it.first.mtu }
|
||||||
|
)
|
||||||
|
).map { it.second.address }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findPreferredAddress(): InetAddress? {
|
||||||
|
val candidates = NetworkInterface.getNetworkInterfaces()
|
||||||
|
.toList()
|
||||||
|
.asSequence()
|
||||||
|
.filter(::isUsableInterface)
|
||||||
|
.flatMap { nif ->
|
||||||
|
nif.interfaceAddresses
|
||||||
|
.asSequence()
|
||||||
|
.mapNotNull { ia ->
|
||||||
|
ia.address.takeIf(::isUsableAddress)?.let { addr ->
|
||||||
|
nif to ia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toList()
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.minWithOrNull(
|
||||||
|
compareBy<Pair<NetworkInterface, InterfaceAddress>>(
|
||||||
|
{ addressScore(it.second.address) },
|
||||||
|
{ interfaceScore(it.first) },
|
||||||
|
{ -it.second.networkPrefixLength.toInt() },
|
||||||
|
{ -it.first.mtu }
|
||||||
|
)
|
||||||
|
)?.second?.address
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsableInterface(nif: NetworkInterface): Boolean {
|
||||||
|
val name = nif.name.lowercase()
|
||||||
|
return try {
|
||||||
|
// must be up, not loopback/virtual/PtP, have a MAC, not Docker/tun/etc.
|
||||||
|
nif.isUp
|
||||||
|
&& !nif.isLoopback
|
||||||
|
&& !nif.isPointToPoint
|
||||||
|
&& !nif.isVirtual
|
||||||
|
&& !name.startsWith("docker")
|
||||||
|
&& !name.startsWith("veth")
|
||||||
|
&& !name.startsWith("br-")
|
||||||
|
&& !name.startsWith("virbr")
|
||||||
|
&& !name.startsWith("vmnet")
|
||||||
|
&& !name.startsWith("tun")
|
||||||
|
&& !name.startsWith("tap")
|
||||||
|
} catch (e: SocketException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUsableAddress(addr: InetAddress): Boolean {
|
||||||
|
return when {
|
||||||
|
addr.isAnyLocalAddress -> false // 0.0.0.0 / ::
|
||||||
|
addr.isLoopbackAddress -> false
|
||||||
|
addr.isLinkLocalAddress -> false // 169.254.x.x or fe80::/10
|
||||||
|
addr.isMulticastAddress -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun interfaceScore(nif: NetworkInterface): Int {
|
||||||
|
val name = nif.name.lowercase()
|
||||||
|
return when {
|
||||||
|
name.matches(Regex("^(eth|enp|eno|ens|em)\\d+")) -> 0
|
||||||
|
name.startsWith("eth") || name.contains("ethernet") -> 0
|
||||||
|
name.matches(Regex("^(wlan|wlp)\\d+")) -> 1
|
||||||
|
name.contains("wi-fi") || name.contains("wifi") -> 1
|
||||||
|
else -> 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addressScore(addr: InetAddress): Int {
|
||||||
|
return when (addr) {
|
||||||
|
is Inet4Address -> {
|
||||||
|
val octets = addr.address.map { it.toInt() and 0xFF }
|
||||||
|
when {
|
||||||
|
octets[0] == 10 -> 0 // 10/8
|
||||||
|
octets[0] == 192 && octets[1] == 168 -> 0 // 192.168/16
|
||||||
|
octets[0] == 172 && octets[1] in 16..31 -> 0 // 172.16–31/12
|
||||||
|
else -> 1 // public IPv4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is Inet6Address -> {
|
||||||
|
// ULA (fc00::/7) vs global vs others
|
||||||
|
val b0 = addr.address[0].toInt() and 0xFF
|
||||||
|
when {
|
||||||
|
(b0 and 0xFE) == 0xFC -> 2 // ULA
|
||||||
|
(b0 and 0xE0) == 0x20 -> 3 // global
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> Enumeration<T>.toList(): List<T> = Collections.list(this)
|
||||||
|
|
||||||
|
fun <T> RequestBuilder<T>.withMaxSizePx(maxSizePx: Int = 1920): RequestBuilder<T> {
|
||||||
|
return this;
|
||||||
|
//.downsample(DownsampleStrategy.AT_MOST)
|
||||||
|
//.override(maxSizePx, maxSizePx)
|
||||||
|
//.centerInside()
|
||||||
}
|
}
|
||||||
@@ -107,10 +107,9 @@ class AddSourceActivity : AppCompatActivity() {
|
|||||||
onNewIntent(intent);
|
onNewIntent(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent?) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
var url = intent?.dataString;
|
var url = intent.dataString;
|
||||||
|
|
||||||
if(url == null)
|
if(url == null)
|
||||||
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
UIDialogs.showDialog(this, R.drawable.ic_error, getString(R.string.no_valid_url_provided), null, null,
|
||||||
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
0, UIDialogs.Action(getString(R.string.ok), { finish() }, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import com.futo.platformplayer.*
|
import com.futo.platformplayer.*
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.views.overlays.slideup.SlideUpMenuTextInput
|
||||||
import com.google.zxing.integration.android.IntentIntegrator
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
|
|
||||||
class AddSourceOptionsActivity : AppCompatActivity() {
|
class AddSourceOptionsActivity : AppCompatActivity() {
|
||||||
lateinit var _buttonBack: ImageButton;
|
lateinit var _buttonBack: ImageButton;
|
||||||
|
|
||||||
|
lateinit var _overlayContainer: FrameLayout;
|
||||||
lateinit var _buttonQR: BigButton;
|
lateinit var _buttonQR: BigButton;
|
||||||
lateinit var _buttonBrowse: BigButton;
|
lateinit var _buttonBrowse: BigButton;
|
||||||
lateinit var _buttonURL: BigButton;
|
lateinit var _buttonURL: BigButton;
|
||||||
@@ -54,6 +56,7 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_add_source_options);
|
setContentView(R.layout.activity_add_source_options);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
|
|
||||||
|
_overlayContainer = findViewById(R.id.overlay_container);
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
_buttonBack = findViewById(R.id.button_back);
|
||||||
|
|
||||||
_buttonQR = findViewById(R.id.option_qr);
|
_buttonQR = findViewById(R.id.option_qr);
|
||||||
@@ -81,7 +84,25 @@ class AddSourceOptionsActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buttonURL.onClick.subscribe {
|
_buttonURL.onClick.subscribe {
|
||||||
UIDialogs.toast(this, getString(R.string.not_implemented_yet));
|
val nameInput = SlideUpMenuTextInput(this, "ex. https://yourplugin.com/config.json");
|
||||||
|
UISlideOverlays.showOverlay(_overlayContainer, "Enter your url", "Install", {
|
||||||
|
|
||||||
|
val content = nameInput.text;
|
||||||
|
|
||||||
|
val url = if (content.startsWith("https://")) {
|
||||||
|
content
|
||||||
|
} else if (content.startsWith("grayjay://plugin/")) {
|
||||||
|
content.substring("grayjay://plugin/".length)
|
||||||
|
} else {
|
||||||
|
UIDialogs.toast(this, getString(R.string.not_a_plugin_url))
|
||||||
|
return@showOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, AddSourceActivity::class.java).apply {
|
||||||
|
data = Uri.parse(url);
|
||||||
|
};
|
||||||
|
startActivity(intent);
|
||||||
|
}, nameInput)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,11 +68,15 @@ class CaptchaActivity : AppCompatActivity() {
|
|||||||
intent.getStringExtra("body");
|
intent.getStringExtra("body");
|
||||||
else null;
|
else null;
|
||||||
|
|
||||||
_webView.settings.userAgentString = captchaConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (captchaConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = captchaConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) CaptchaWebViewClient(config) else CaptchaWebViewClient(captchaConfig);
|
val webViewClient = if(config != null) CaptchaWebViewClient(config, capturedUserAgent) else CaptchaWebViewClient(captchaConfig, capturedUserAgent);
|
||||||
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
webViewClient.onCaptchaFinished.subscribe { captcha ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
_callback = null;
|
_callback = null;
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package com.futo.platformplayer.activities
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.futo.platformplayer.*
|
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
|
||||||
import com.futo.platformplayer.views.fields.IField
|
|
||||||
|
|
||||||
class DeveloperActivity : AppCompatActivity() {
|
|
||||||
private lateinit var _form: FieldForm;
|
|
||||||
private lateinit var _buttonBack: ImageButton;
|
|
||||||
|
|
||||||
fun getField(id: String): IField? {
|
|
||||||
return _form.findField(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
DeveloperActivity._lastActivity = this;
|
|
||||||
setContentView(R.layout.activity_dev);
|
|
||||||
setNavigationBarColorAndIcons();
|
|
||||||
|
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
|
||||||
_form = findViewById(R.id.settings_form);
|
|
||||||
|
|
||||||
_form.fromObject(SettingsDev.instance);
|
|
||||||
_form.onChanged.subscribe { _, _ ->
|
|
||||||
_form.setObjectValues();
|
|
||||||
SettingsDev.instance.save();
|
|
||||||
};
|
|
||||||
|
|
||||||
_buttonBack.setOnClickListener {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
|
||||||
super.finish()
|
|
||||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
//TODO: Temporary for solving Settings issues
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private var _lastActivity: DeveloperActivity? = null;
|
|
||||||
|
|
||||||
fun getActivity(): DeveloperActivity? {
|
|
||||||
val act = _lastActivity;
|
|
||||||
if(act != null)
|
|
||||||
return act;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.UpdateInstaller
|
||||||
|
import com.futo.platformplayer.UpdateNotificationManager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class InstallUpdateActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
UpdateNotificationManager.cancelAll(this)
|
||||||
|
|
||||||
|
val version = intent.getIntExtra(UpdateNotificationManager.EXTRA_VERSION, 0)
|
||||||
|
val apkPath = intent.getStringExtra(UpdateNotificationManager.EXTRA_APK_PATH)
|
||||||
|
|
||||||
|
if (version == 0 || apkPath.isNullOrEmpty()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "Missing version or apkPath")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Logger.w("InstallUpdateActivity", "APK file does not exist: $apkPath")
|
||||||
|
UIDialogs.Companion.toast(this, "Update file missing")
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateInstaller.startInstall(this, version, apkFile)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createIntent(context: Context, version: Int, apkPath: String): Intent =
|
||||||
|
Intent(context, InstallUpdateActivity::class.java).apply {
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_VERSION, version)
|
||||||
|
putExtra(UpdateNotificationManager.EXTRA_APK_PATH, apkPath)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.futo.platformplayer.api.media.platforms.js.SourceAuth
|
|||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginAuthConfig
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.matchesDomain
|
||||||
import com.futo.platformplayer.others.LoginWebViewClient
|
import com.futo.platformplayer.others.LoginWebViewClient
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
@@ -60,11 +61,15 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
else throw IllegalStateException("No valid configuration?");
|
else throw IllegalStateException("No valid configuration?");
|
||||||
//TODO: Backwards compat removal?
|
//TODO: Backwards compat removal?
|
||||||
|
|
||||||
_webView.settings.userAgentString = authConfig.userAgent ?: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36";
|
// Only override UA if config specifies one, otherwise keep WebView default (matches Desktop CEF behavior)
|
||||||
|
if (authConfig.userAgent != null)
|
||||||
|
_webView.settings.userAgentString = authConfig.userAgent;
|
||||||
|
// Capture UA on main thread - callback fires on WebView background thread where settings access is not allowed
|
||||||
|
val capturedUserAgent = _webView.settings.userAgentString;
|
||||||
_webView.settings.useWideViewPort = true;
|
_webView.settings.useWideViewPort = true;
|
||||||
_webView.settings.loadWithOverviewMode = true;
|
_webView.settings.loadWithOverviewMode = true;
|
||||||
|
|
||||||
val webViewClient = if(config != null) LoginWebViewClient(config) else LoginWebViewClient(authConfig);
|
val webViewClient = if(config != null) LoginWebViewClient(config, capturedUserAgent) else LoginWebViewClient(authConfig, capturedUserAgent);
|
||||||
|
|
||||||
webViewClient.onLogin.subscribe { auth ->
|
webViewClient.onLogin.subscribe { auth ->
|
||||||
_callback?.let {
|
_callback?.let {
|
||||||
@@ -74,9 +79,26 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
var isFirstLoad = true;
|
var isFirstLoad = true;
|
||||||
|
val loginWarnings = authConfig.loginWarnings?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.Warning>();
|
||||||
|
val uiMods = authConfig.uiMods?.toMutableList() ?: mutableListOf<SourcePluginAuthConfig.UIMod>();
|
||||||
|
var currentScale = 100;
|
||||||
|
var currentDesktop = false;
|
||||||
webViewClient.onPageLoaded.subscribe { view, url ->
|
webViewClient.onPageLoaded.subscribe { view, url ->
|
||||||
_textUrl.setText(url ?: "");
|
_textUrl.setText(url ?: "");
|
||||||
|
|
||||||
|
if(loginWarnings.size > 0 && url != null) {
|
||||||
|
synchronized(loginWarnings) {
|
||||||
|
val warning = loginWarnings.find { url.matches(it.getRegex()) };
|
||||||
|
if(warning != null) {
|
||||||
|
if(warning.once == true)
|
||||||
|
loginWarnings.remove(warning);
|
||||||
|
UIDialogs.showDialog(this@LoginActivity, R.drawable.ic_warning_yellow, warning.text ?: "", warning.details ?: "", null, 0,
|
||||||
|
UIDialogs.Action("Understood", {
|
||||||
|
}, UIDialogs.ActionStyle.PRIMARY));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if(!isFirstLoad)
|
if(!isFirstLoad)
|
||||||
return@subscribe;
|
return@subscribe;
|
||||||
isFirstLoad = false;
|
isFirstLoad = false;
|
||||||
@@ -86,6 +108,35 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
//TODO: Find most reliable way to wait for page js to finish
|
//TODO: Find most reliable way to wait for page js to finish
|
||||||
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
view?.evaluateJavascript("setTimeout(()=> document.querySelector(\"${authConfig.loginButton}\")?.click(), 1000)", {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
var specifiedScale = false;
|
||||||
|
var specifiedDesktop = false;
|
||||||
|
if(uiMods.size > 0 && url != null) {
|
||||||
|
synchronized(uiMods) {
|
||||||
|
val uimod = uiMods.find { url.matches(it.getRegex()) };
|
||||||
|
if(uimod != null) {
|
||||||
|
if(uimod.scale != null) {
|
||||||
|
currentScale =(uimod.scale * 100).toInt();
|
||||||
|
_webView.setInitialScale(currentScale);
|
||||||
|
specifiedScale = true;
|
||||||
|
}
|
||||||
|
if(uimod.desktop != null && uimod.desktop) {
|
||||||
|
_webView.settings.useWideViewPort = true;
|
||||||
|
specifiedDesktop = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(!specifiedScale && currentScale != 100) {
|
||||||
|
currentScale = (100).toInt();
|
||||||
|
_webView.setInitialScale(currentScale);
|
||||||
|
}
|
||||||
|
if(!specifiedDesktop && currentDesktop) {
|
||||||
|
_webView.settings.useWideViewPort = false;
|
||||||
|
currentDesktop = false;
|
||||||
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
_webView.settings.domStorageEnabled = true;
|
_webView.settings.domStorageEnabled = true;
|
||||||
|
|
||||||
@@ -113,7 +164,7 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val TAG = "LoginActivity";
|
private val TAG = "LoginActivity";
|
||||||
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#_ ]*");
|
private val REGEX_LOGIN_BUTTON = Regex("[a-zA-Z\\-\\.#:_ ]*");
|
||||||
|
|
||||||
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
private var _callback: ((SourceAuth?) -> Unit)? = null;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+113
-16
@@ -13,13 +13,18 @@ import android.view.View
|
|||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateApp.Companion.withContext
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
|
import com.futo.platformplayer.activities.QRCodeFullscreenActivity
|
||||||
import com.futo.polycentric.core.ContentType
|
import com.futo.polycentric.core.ContentType
|
||||||
import com.futo.polycentric.core.SignedEvent
|
import com.futo.polycentric.core.SignedEvent
|
||||||
import com.futo.polycentric.core.StorageTypeCRDTItem
|
import com.futo.polycentric.core.StorageTypeCRDTItem
|
||||||
@@ -27,8 +32,13 @@ import com.futo.polycentric.core.StorageTypeCRDTSetItem
|
|||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.toBase64Url
|
import com.futo.polycentric.core.toBase64Url
|
||||||
import com.google.zxing.BarcodeFormat
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
import com.google.zxing.MultiFormatWriter
|
import com.google.zxing.MultiFormatWriter
|
||||||
import com.google.zxing.common.BitMatrix
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import userpackage.Protocol
|
import userpackage.Protocol
|
||||||
import userpackage.Protocol.ExportBundle
|
import userpackage.Protocol.ExportBundle
|
||||||
import userpackage.Protocol.URLInfo
|
import userpackage.Protocol.URLInfo
|
||||||
@@ -36,9 +46,26 @@ import userpackage.Protocol.URLInfo
|
|||||||
class PolycentricBackupActivity : AppCompatActivity() {
|
class PolycentricBackupActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonShare: BigButton;
|
private lateinit var _buttonShare: BigButton;
|
||||||
private lateinit var _buttonCopy: BigButton;
|
private lateinit var _buttonCopy: BigButton;
|
||||||
|
private lateinit var _buttonExportFile: BigButton;
|
||||||
private lateinit var _imageQR: ImageView;
|
private lateinit var _imageQR: ImageView;
|
||||||
private lateinit var _exportBundle: String;
|
private lateinit var _exportBundle: String;
|
||||||
private lateinit var _textQR: TextView;
|
private lateinit var _textQR: TextView;
|
||||||
|
private lateinit var _textQRHint: TextView;
|
||||||
|
private lateinit var _loader: View
|
||||||
|
|
||||||
|
private val _createDocumentLauncher = registerForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
|
uri?.let { fileUri ->
|
||||||
|
try {
|
||||||
|
contentResolver.openOutputStream(fileUri)?.use { outputStream ->
|
||||||
|
outputStream.write(_exportBundle.toByteArray())
|
||||||
|
}
|
||||||
|
UIDialogs.toast(this, getString(R.string.profile_saved_successfully))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to write to document", e)
|
||||||
|
UIDialogs.toast(this, "Failed to save profile: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
@@ -49,24 +76,75 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_polycentric_backup);
|
setContentView(R.layout.activity_polycentric_backup);
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons();
|
||||||
|
|
||||||
_buttonShare = findViewById(R.id.button_share);
|
_buttonShare = findViewById(R.id.button_share)
|
||||||
_buttonCopy = findViewById(R.id.button_copy);
|
_buttonCopy = findViewById(R.id.button_copy)
|
||||||
_imageQR = findViewById(R.id.image_qr);
|
_buttonExportFile = findViewById(R.id.button_export_file)
|
||||||
_textQR = findViewById(R.id.text_qr);
|
_imageQR = findViewById(R.id.image_qr)
|
||||||
|
_textQR = findViewById(R.id.text_qr)
|
||||||
|
_textQRHint = findViewById(R.id.text_qr_hint)
|
||||||
|
_loader = findViewById(R.id.progress_loader)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
|
|
||||||
_exportBundle = createExportBundle();
|
_imageQR.visibility = View.INVISIBLE
|
||||||
|
_textQR.visibility = View.INVISIBLE
|
||||||
|
_textQRHint.visibility = View.INVISIBLE
|
||||||
|
_loader.visibility = View.VISIBLE
|
||||||
|
_buttonShare.visibility = View.INVISIBLE
|
||||||
|
_buttonCopy.visibility = View.INVISIBLE
|
||||||
|
_buttonExportFile.visibility = View.INVISIBLE
|
||||||
|
|
||||||
try {
|
lifecycleScope.launch {
|
||||||
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt();
|
val bundle = withContext(Dispatchers.IO) { createExportBundle() }
|
||||||
val qrCodeBitmap = generateQRCode(_exportBundle, dimension, dimension);
|
_exportBundle = bundle
|
||||||
_imageQR.setImageBitmap(qrCodeBitmap);
|
Logger.i(TAG, "Export bundle created, length: ${bundle.length}")
|
||||||
} catch (e: Exception) {
|
|
||||||
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e);
|
try {
|
||||||
_imageQR.visibility = View.INVISIBLE;
|
val pair = withContext(Dispatchers.IO) {
|
||||||
_textQR.visibility = View.INVISIBLE;
|
if (!isContentSuitableForQRCode(bundle)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val dimension = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics
|
||||||
|
).toInt()
|
||||||
|
val qr = generateQRCode(bundle, dimension, dimension)
|
||||||
|
Pair(bundle, qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_imageQR.setImageBitmap(pair.second)
|
||||||
|
_imageQR.visibility = View.VISIBLE
|
||||||
|
_textQR.visibility = View.VISIBLE
|
||||||
|
_textQRHint.visibility = View.VISIBLE
|
||||||
|
_buttonShare.visibility = View.VISIBLE
|
||||||
|
_buttonCopy.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
_imageQR.setOnClickListener {
|
||||||
|
val intent = QRCodeFullscreenActivity.createIntent(this@PolycentricBackupActivity, _exportBundle)
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val byteSize = bundle.toByteArray(Charsets.UTF_8).size
|
||||||
|
Logger.e(TAG, "QR code generation failed. Bundle length: ${bundle.length} chars, ${byteSize} bytes, Error: ${e.message}", e)
|
||||||
|
|
||||||
|
if (e.message?.contains("Data too big") == true) {
|
||||||
|
_textQR.text = getString(R.string.qr_code_too_large_use_file_export)
|
||||||
|
_buttonExportFile.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
_textQR.text = getString(R.string.failed_to_generate_qr_code)
|
||||||
|
}
|
||||||
|
|
||||||
|
_textQR.visibility = View.VISIBLE
|
||||||
|
_textQRHint.visibility = View.INVISIBLE
|
||||||
|
_buttonShare.visibility = View.VISIBLE
|
||||||
|
_buttonCopy.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Hide QR image since generation failed
|
||||||
|
_imageQR.visibility = View.INVISIBLE
|
||||||
|
} finally {
|
||||||
|
_loader.visibility = View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_buttonShare.onClick.subscribe {
|
_buttonShare.onClick.subscribe {
|
||||||
@@ -79,11 +157,29 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
val clip = ClipData.newPlainText(getString(R.string.copied_text), _exportBundle);
|
||||||
clipboard.setPrimaryClip(clip);
|
clipboard.setPrimaryClip(clip);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_buttonExportFile.onClick.subscribe {
|
||||||
|
val fileName = "polycentric_profile_${System.currentTimeMillis()}.txt"
|
||||||
|
_createDocumentLauncher.launch(fileName)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||||
|
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||||
|
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
if (!isContentSuitableForQRCode(content)) {
|
||||||
return bitMatrixToBitmap(bitMatrix);
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||||
|
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||||
|
hints[EncodeHintType.MARGIN] = 1
|
||||||
|
|
||||||
|
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||||
|
return bitMatrixToBitmap(bitMatrix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
@@ -174,7 +270,8 @@ class PolycentricBackupActivity : AppCompatActivity() {
|
|||||||
.setBody(exportBundle.toByteString())
|
.setBody(exportBundle.toByteString())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
return "polycentric://" + urlInfo.toByteArray().toBase64Url()
|
val data = urlInfo.toByteArray()
|
||||||
|
return "polycentric://" + data.toBase64Url()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+41
-23
@@ -3,6 +3,7 @@ package com.futo.platformplayer.activities
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
@@ -10,15 +11,16 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
|
import com.futo.platformplayer.views.LoaderView
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.ProcessHandle
|
import com.futo.polycentric.core.ProcessHandle
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -27,6 +29,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton;
|
||||||
private lateinit var _profileName: EditText;
|
private lateinit var _profileName: EditText;
|
||||||
private lateinit var _buttonCreate: LinearLayout;
|
private lateinit var _buttonCreate: LinearLayout;
|
||||||
|
private lateinit var _loader: LoaderView;
|
||||||
private val TAG = "PolycentricCreateProfileActivity";
|
private val TAG = "PolycentricCreateProfileActivity";
|
||||||
|
|
||||||
private var _creating = false;
|
private var _creating = false;
|
||||||
@@ -43,6 +46,7 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
_buttonHelp = findViewById(R.id.button_help);
|
_buttonHelp = findViewById(R.id.button_help);
|
||||||
_profileName = findViewById(R.id.edit_profile_name);
|
_profileName = findViewById(R.id.edit_profile_name);
|
||||||
_buttonCreate = findViewById(R.id.button_create_profile);
|
_buttonCreate = findViewById(R.id.button_create_profile);
|
||||||
|
_loader = findViewById(R.id.loader);
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
@@ -65,35 +69,49 @@ class PolycentricCreateProfileActivity : AppCompatActivity() {
|
|||||||
return@setOnClickListener;
|
return@setOnClickListener;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_profileName.isEnabled = false;
|
||||||
|
_buttonCreate.visibility = View.GONE;
|
||||||
|
_loader.start();
|
||||||
|
_loader.visibility = View.VISIBLE;
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val processHandle: ProcessHandle;
|
val processHandle: ProcessHandle;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
processHandle = ProcessHandle.create();
|
|
||||||
Store.instance.addProcessSecret(processHandle.processSecret);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
processHandle = ProcessHandle.create();
|
||||||
|
Store.instance.addProcessSecret(processHandle.processSecret);
|
||||||
|
|
||||||
|
try {
|
||||||
|
PolycentricStorage.instance.addProcessSecret(processHandle.processSecret)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
processHandle.addServer(ApiMethods.SERVER);
|
||||||
|
processHandle.setUsername(username);
|
||||||
|
StatePolycentric.instance.setProcessHandle(processHandle);
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
|
||||||
|
return@launch;
|
||||||
|
} finally {
|
||||||
|
_creating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
processHandle.addServer(PolycentricCache.SERVER);
|
try {
|
||||||
processHandle.setUsername(username);
|
Logger.i(TAG, "Started backfill");
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
processHandle.fullyBackfillServersAnnounceExceptions();
|
||||||
} catch (e: Throwable) {
|
Logger.i(TAG, "Finished backfill");
|
||||||
Logger.e(TAG, getString(R.string.failed_to_create_profile), e);
|
} catch (e: Throwable) {
|
||||||
return@launch;
|
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
||||||
} finally {
|
}
|
||||||
_creating = false;
|
|
||||||
}
|
}
|
||||||
|
finally {
|
||||||
try {
|
withContext(Dispatchers.Main) {
|
||||||
Logger.i(TAG, "Started backfill");
|
_profileName.isEnabled = true;
|
||||||
processHandle.fullyBackfillServersAnnounceExceptions();
|
_buttonCreate.visibility = View.VISIBLE;
|
||||||
Logger.i(TAG, "Finished backfill");
|
_loader.stop();
|
||||||
} catch (e: Throwable) {
|
_loader.visibility = View.GONE;
|
||||||
Logger.e(TAG, getString(R.string.failed_to_fully_backfill_servers), e);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.os.Bundle
|
|||||||
import android.util.TypedValue
|
import android.util.TypedValue
|
||||||
import android.widget.ImageButton
|
import android.widget.ImageButton
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.target.CustomTarget
|
import com.bumptech.glide.request.target.CustomTarget
|
||||||
@@ -28,6 +29,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||||||
private lateinit var _buttonNewProfile: BigButton;
|
private lateinit var _buttonNewProfile: BigButton;
|
||||||
private lateinit var _buttonImportProfile: BigButton;
|
private lateinit var _buttonImportProfile: BigButton;
|
||||||
private lateinit var _layoutButtons: LinearLayout;
|
private lateinit var _layoutButtons: LinearLayout;
|
||||||
|
private lateinit var _scroll: ScrollView;
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
@@ -42,6 +44,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||||||
_buttonNewProfile = findViewById(R.id.button_new_profile);
|
_buttonNewProfile = findViewById(R.id.button_new_profile);
|
||||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
||||||
_layoutButtons = findViewById(R.id.layout_buttons);
|
_layoutButtons = findViewById(R.id.layout_buttons);
|
||||||
|
_scroll = findViewById(R.id.scroll);
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
finish();
|
finish();
|
||||||
};
|
};
|
||||||
@@ -78,6 +81,7 @@ class PolycentricHomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
_layoutButtons.addView(profileButton, 0);
|
_layoutButtons.addView(profileButton, 0);
|
||||||
}
|
}
|
||||||
|
_scroll.invalidate();
|
||||||
|
|
||||||
_buttonHelp.setOnClickListener {
|
_buttonHelp.setOnClickListener {
|
||||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
||||||
|
|||||||
+134
-62
@@ -12,12 +12,12 @@ import androidx.lifecycle.lifecycleScope
|
|||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
|
||||||
import com.futo.platformplayer.polycentric.PolycentricStorage
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.KeyPair
|
import com.futo.polycentric.core.KeyPair
|
||||||
import com.futo.polycentric.core.Process
|
import com.futo.polycentric.core.Process
|
||||||
import com.futo.polycentric.core.ProcessSecret
|
import com.futo.polycentric.core.ProcessSecret
|
||||||
@@ -32,100 +32,166 @@ import userpackage.Protocol
|
|||||||
import userpackage.Protocol.ExportBundle
|
import userpackage.Protocol.ExportBundle
|
||||||
|
|
||||||
class PolycentricImportProfileActivity : AppCompatActivity() {
|
class PolycentricImportProfileActivity : AppCompatActivity() {
|
||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton
|
||||||
private lateinit var _buttonScanProfile: LinearLayout;
|
private lateinit var _buttonScanProfile: LinearLayout
|
||||||
private lateinit var _buttonImportProfile: LinearLayout;
|
private lateinit var _buttonImportFile: LinearLayout
|
||||||
private lateinit var _editProfile: EditText;
|
private lateinit var _buttonImportProfile: LinearLayout
|
||||||
private lateinit var _loaderOverlay: LoaderOverlay;
|
private lateinit var _editProfile: EditText
|
||||||
|
private lateinit var _loaderOverlay: LoaderOverlay
|
||||||
|
|
||||||
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val _qrCodeResultLauncher =
|
||||||
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
scanResult?.let {
|
val scanResult =
|
||||||
if (it.contents != null) {
|
IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
val scannedUrl = it.contents
|
scanResult?.let {
|
||||||
import(scannedUrl)
|
if (it.contents != null) {
|
||||||
|
val scannedUrl = it.contents
|
||||||
|
import(scannedUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _filePickerLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||||
|
uri?.let { fileUri ->
|
||||||
|
try {
|
||||||
|
// Check file size before reading
|
||||||
|
val fileSize =
|
||||||
|
contentResolver.openFileDescriptor(fileUri, "r")?.statSize ?: 0
|
||||||
|
val maxFileSize = 10 * 1024 * 1024 // 10MB limit
|
||||||
|
|
||||||
|
if (fileSize > maxFileSize) {
|
||||||
|
UIDialogs.toast(this, "File too large. Maximum size is 10MB.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileSize == 0L) {
|
||||||
|
UIDialogs.toast(this, "Selected file is empty.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
val content =
|
||||||
|
contentResolver
|
||||||
|
.openInputStream(fileUri)
|
||||||
|
?.bufferedReader()
|
||||||
|
?.readText()
|
||||||
|
content?.let { fileContent ->
|
||||||
|
val trimmedContent = fileContent.trim()
|
||||||
|
|
||||||
|
// Check if content is empty after trimming
|
||||||
|
if (trimmedContent.isEmpty()) {
|
||||||
|
UIDialogs.toast(this, "Selected file contains no data.")
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if content looks like a valid polycentric URL
|
||||||
|
if (!trimmedContent.startsWith("polycentric://")) {
|
||||||
|
UIDialogs.toast(
|
||||||
|
this,
|
||||||
|
"Selected file does not contain a valid polycentric profile URL."
|
||||||
|
)
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
import(trimmedContent)
|
||||||
|
}
|
||||||
|
?: run { UIDialogs.toast(this, "Could not read file content.") }
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Logger.e(TAG, "Security exception reading file", e)
|
||||||
|
UIDialogs.toast(this, "Permission denied to read file.")
|
||||||
|
} catch (e: OutOfMemoryError) {
|
||||||
|
Logger.e(TAG, "Out of memory reading file", e)
|
||||||
|
UIDialogs.toast(this, "File too large to process.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to read file", e)
|
||||||
|
UIDialogs.toast(this, "Failed to read file: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_polycentric_import_profile);
|
setContentView(R.layout.activity_polycentric_import_profile)
|
||||||
setNavigationBarColorAndIcons();
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
_buttonHelp = findViewById(R.id.button_help);
|
_buttonHelp = findViewById(R.id.button_help)
|
||||||
_buttonScanProfile = findViewById(R.id.button_scan_profile);
|
_buttonScanProfile = findViewById(R.id.button_scan_profile)
|
||||||
_buttonImportProfile = findViewById(R.id.button_import_profile);
|
_buttonImportFile = findViewById(R.id.button_import_file)
|
||||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
_buttonImportProfile = findViewById(R.id.button_import_profile)
|
||||||
_editProfile = findViewById(R.id.edit_profile);
|
_loaderOverlay = findViewById(R.id.loader_overlay)
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
_editProfile = findViewById(R.id.edit_profile)
|
||||||
finish();
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener { finish() }
|
||||||
};
|
|
||||||
|
|
||||||
_buttonHelp.setOnClickListener {
|
_buttonHelp.setOnClickListener {
|
||||||
startActivity(Intent(this, PolycentricWhyActivity::class.java));
|
startActivity(Intent(this, PolycentricWhyActivity::class.java))
|
||||||
};
|
}
|
||||||
|
|
||||||
_buttonScanProfile.setOnClickListener {
|
_buttonScanProfile.setOnClickListener {
|
||||||
val integrator = IntentIntegrator(this)
|
val integrator = IntentIntegrator(this)
|
||||||
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||||
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||||
integrator.setOrientationLocked(true);
|
integrator.setOrientationLocked(true)
|
||||||
integrator.setCameraId(0)
|
integrator.setCameraId(0)
|
||||||
integrator.setBeepEnabled(false)
|
integrator.setBeepEnabled(false)
|
||||||
integrator.setBarcodeImageEnabled(true)
|
integrator.setBarcodeImageEnabled(true)
|
||||||
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
integrator.setCaptureActivity(QRCaptureActivity::class.java)
|
||||||
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||||
};
|
}
|
||||||
|
|
||||||
|
_buttonImportFile.setOnClickListener { _filePickerLauncher.launch("text/plain") }
|
||||||
|
|
||||||
_buttonImportProfile.setOnClickListener {
|
_buttonImportProfile.setOnClickListener {
|
||||||
if (_editProfile.text.isEmpty()) {
|
if (_editProfile.text.isEmpty()) {
|
||||||
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data));
|
UIDialogs.toast(this, getString(R.string.text_field_does_not_contain_any_data))
|
||||||
return@setOnClickListener;
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
|
|
||||||
import(_editProfile.text.toString());
|
import(_editProfile.text.toString())
|
||||||
};
|
}
|
||||||
|
|
||||||
val url = intent.getStringExtra("url");
|
val url = intent.getStringExtra("url")
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
import(url);
|
import(url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun import(url: String) {
|
private fun import(url: String) {
|
||||||
if (!url.startsWith("polycentric://")) {
|
if (!url.startsWith("polycentric://")) {
|
||||||
UIDialogs.toast(this, getString(R.string.not_a_valid_url));
|
UIDialogs.toast(this, getString(R.string.not_a_valid_url))
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_loaderOverlay.show()
|
_loaderOverlay.show()
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val data = url.substring("polycentric://".length).base64UrlToByteArray();
|
val data = url.substring("polycentric://".length).base64UrlToByteArray()
|
||||||
val urlInfo = Protocol.URLInfo.parseFrom(data);
|
val urlInfo = Protocol.URLInfo.parseFrom(data)
|
||||||
|
|
||||||
if (urlInfo.urlType != 3L) {
|
if (urlInfo.urlType != 3L) {
|
||||||
throw Exception("Expected urlInfo struct of type ExportBundle")
|
throw Exception("Expected urlInfo struct of type ExportBundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
val exportBundle = ExportBundle.parseFrom(urlInfo.body);
|
val exportBundle = ExportBundle.parseFrom(urlInfo.body)
|
||||||
val keyPair = KeyPair.fromProto(exportBundle.keyPair);
|
val keyPair = KeyPair.fromProto(exportBundle.keyPair)
|
||||||
|
|
||||||
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey);
|
val existingProcessSecret = Store.instance.getProcessSecret(keyPair.publicKey)
|
||||||
if (existingProcessSecret != null) {
|
if (existingProcessSecret != null) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.this_profile_is_already_imported));
|
UIDialogs.toast(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
getString(R.string.this_profile_is_already_imported)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return@launch;
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val processSecret = ProcessSecret(keyPair, Process.random());
|
val processSecret = ProcessSecret(keyPair, Process.random())
|
||||||
Store.instance.addProcessSecret(processSecret);
|
Store.instance.addProcessSecret(processSecret)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
PolycentricStorage.instance.addProcessSecret(processSecret)
|
PolycentricStorage.instance.addProcessSecret(processSecret)
|
||||||
@@ -133,37 +199,43 @@ class PolycentricImportProfileActivity : AppCompatActivity() {
|
|||||||
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
Logger.e(TAG, "Failed to save process secret to secret storage.", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
val processHandle = processSecret.toProcessHandle();
|
val processHandle = processSecret.toProcessHandle()
|
||||||
|
|
||||||
for (e in exportBundle.events.eventsList) {
|
for (e in exportBundle.events.eventsList) {
|
||||||
try {
|
try {
|
||||||
val se = SignedEvent.fromProto(e);
|
val se = SignedEvent.fromProto(e)
|
||||||
Store.instance.putSignedEvent(se);
|
Store.instance.putSignedEvent(se)
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Ignored invalid event", e);
|
Logger.w(TAG, "Ignored invalid event", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatePolycentric.instance.setProcessHandle(processHandle);
|
StatePolycentric.instance.setProcessHandle(processHandle)
|
||||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER);
|
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
startActivity(Intent(this@PolycentricImportProfileActivity, PolycentricProfileActivity::class.java));
|
startActivity(
|
||||||
finish();
|
Intent(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
PolycentricProfileActivity::class.java
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
Logger.w(TAG, "Failed to import profile", e);
|
Logger.w(TAG, "Failed to import profile", e)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
UIDialogs.toast(this@PolycentricImportProfileActivity, getString(R.string.failed_to_import_profile) + " '${e.message}'");
|
UIDialogs.toast(
|
||||||
|
this@PolycentricImportProfileActivity,
|
||||||
|
getString(R.string.failed_to_import_profile) + " '${e.message}'"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) { _loaderOverlay.hide() }
|
||||||
_loaderOverlay.hide();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "PolycentricImportProfileActivity";
|
private const val TAG = "PolycentricImportProfileActivity"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.SeekBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.polycentric.ModerationsManager
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
|
||||||
|
class PolycentricModerationActivity : AppCompatActivity() {
|
||||||
|
private lateinit var _seekbarOffensive: SeekBar
|
||||||
|
private lateinit var _seekbarExplicit: SeekBar
|
||||||
|
private lateinit var _seekbarViolence: SeekBar
|
||||||
|
private lateinit var _textOffensiveDesc: TextView
|
||||||
|
private lateinit var _textExplicitDesc: TextView
|
||||||
|
private lateinit var _textViolenceDesc: TextView
|
||||||
|
private lateinit var _textOffensiveValue: TextView
|
||||||
|
private lateinit var _textExplicitValue: TextView
|
||||||
|
private lateinit var _textViolenceValue: TextView
|
||||||
|
private lateinit var _moderationsManager: ModerationsManager
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_polycentric_moderation)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
_moderationsManager = ModerationsManager.getInstance()
|
||||||
|
try {
|
||||||
|
_moderationsManager = ModerationsManager.getInstance()
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_seekbarOffensive = findViewById(R.id.seekbar_offensive)
|
||||||
|
_seekbarExplicit = findViewById(R.id.seekbar_explicit)
|
||||||
|
_seekbarViolence = findViewById(R.id.seekbar_violence)
|
||||||
|
_textOffensiveDesc = findViewById(R.id.text_offensive_desc)
|
||||||
|
_textExplicitDesc = findViewById(R.id.text_explicit_desc)
|
||||||
|
_textViolenceDesc = findViewById(R.id.text_violence_desc)
|
||||||
|
_textOffensiveValue = findViewById(R.id.text_offensive_value)
|
||||||
|
_textExplicitValue = findViewById(R.id.text_explicit_value)
|
||||||
|
_textViolenceValue = findViewById(R.id.text_violence_value)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings()
|
||||||
|
setupListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadSettings() {
|
||||||
|
val levels = _moderationsManager.moderationLevels.value ?: mapOf()
|
||||||
|
|
||||||
|
val offensiveLevel = levels["hate"] ?: 2
|
||||||
|
val explicitLevel = levels["sexual"] ?: 1
|
||||||
|
val violenceLevel = levels["violence"] ?: 1
|
||||||
|
|
||||||
|
_seekbarOffensive.progress = offensiveLevel
|
||||||
|
_seekbarExplicit.progress = explicitLevel
|
||||||
|
_seekbarViolence.progress = violenceLevel
|
||||||
|
|
||||||
|
updateDescriptionText(_seekbarOffensive, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||||
|
updateDescriptionText(_seekbarExplicit, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||||
|
updateDescriptionText(_seekbarViolence, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupListeners() {
|
||||||
|
_seekbarOffensive.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textOffensiveDesc, _textOffensiveValue, getOffensiveDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("hate", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
_seekbarExplicit.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textExplicitDesc, _textExplicitValue, getExplicitDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("sexual", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
|
||||||
|
_seekbarViolence.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
|
||||||
|
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
|
||||||
|
updateDescriptionText(seekBar, _textViolenceDesc, _textViolenceValue, getViolenceDescriptions())
|
||||||
|
if (fromUser) {
|
||||||
|
_moderationsManager.setModerationLevel("violence", progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
override fun onStopTrackingTouch(seekBar: SeekBar?) {}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDescriptionText(seekBar: SeekBar?, textDesc: TextView, textValue: TextView, descriptions: Array<String>) {
|
||||||
|
val progress = seekBar?.progress ?: 0
|
||||||
|
textDesc.text = descriptions[progress]
|
||||||
|
textValue.text = progress.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOffensiveDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"Neutral, general terms, no bias or hate.",
|
||||||
|
"Mildly sensitive, factual.",
|
||||||
|
"Potentially offensive content",
|
||||||
|
"Offensive content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getExplicitDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"No explicit content",
|
||||||
|
"Mildly suggestive, factual or educational",
|
||||||
|
"Moderate sexual content, non-graphic",
|
||||||
|
"Explicit sexual content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getViolenceDescriptions(): Array<String> {
|
||||||
|
return arrayOf(
|
||||||
|
"Non-violent",
|
||||||
|
"Mild violence, factual or contextual",
|
||||||
|
"Moderate violence, some graphic content.",
|
||||||
|
"Graphic violence"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,18 +21,20 @@ import com.bumptech.glide.Glide
|
|||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
import com.futo.platformplayer.UIDialogs
|
import com.futo.platformplayer.UIDialogs
|
||||||
import com.futo.platformplayer.dp
|
import com.futo.platformplayer.dp
|
||||||
import com.futo.platformplayer.fullyBackfillServersAnnounceExceptions
|
|
||||||
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
import com.futo.platformplayer.images.GlideHelper.Companion.crossfade
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.polycentric.PolycentricCache
|
import com.futo.platformplayer.polycentric.PolycentricStorage
|
||||||
import com.futo.platformplayer.selectBestImage
|
import com.futo.platformplayer.selectBestImage
|
||||||
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
import com.futo.platformplayer.states.StateApp
|
import com.futo.platformplayer.states.StateApp
|
||||||
import com.futo.platformplayer.states.StatePolycentric
|
import com.futo.platformplayer.states.StatePolycentric
|
||||||
import com.futo.platformplayer.views.buttons.BigButton
|
import com.futo.platformplayer.views.buttons.BigButton
|
||||||
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
import com.futo.platformplayer.views.overlays.LoaderOverlay
|
||||||
|
import com.futo.polycentric.core.ApiMethods
|
||||||
import com.futo.polycentric.core.Store
|
import com.futo.polycentric.core.Store
|
||||||
import com.futo.polycentric.core.SystemState
|
import com.futo.polycentric.core.SystemState
|
||||||
|
import com.futo.polycentric.core.fullyBackfillServersAnnounceExceptions
|
||||||
|
import com.futo.polycentric.core.systemToURLInfoSystemLinkUrl
|
||||||
import com.futo.polycentric.core.toBase64Url
|
import com.futo.polycentric.core.toBase64Url
|
||||||
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
import com.futo.polycentric.core.toURLInfoSystemLinkUrl
|
||||||
import com.github.dhaval2404.imagepicker.ImagePicker
|
import com.github.dhaval2404.imagepicker.ImagePicker
|
||||||
@@ -47,6 +49,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
private lateinit var _buttonHelp: ImageButton;
|
private lateinit var _buttonHelp: ImageButton;
|
||||||
private lateinit var _editName: EditText;
|
private lateinit var _editName: EditText;
|
||||||
private lateinit var _buttonExport: BigButton;
|
private lateinit var _buttonExport: BigButton;
|
||||||
|
private lateinit var _buttonModeration: BigButton;
|
||||||
private lateinit var _buttonLogout: BigButton;
|
private lateinit var _buttonLogout: BigButton;
|
||||||
private lateinit var _buttonDelete: BigButton;
|
private lateinit var _buttonDelete: BigButton;
|
||||||
private lateinit var _username: String;
|
private lateinit var _username: String;
|
||||||
@@ -68,10 +71,14 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
_imagePolycentric = findViewById(R.id.image_polycentric);
|
_imagePolycentric = findViewById(R.id.image_polycentric);
|
||||||
_editName = findViewById(R.id.edit_profile_name);
|
_editName = findViewById(R.id.edit_profile_name);
|
||||||
_buttonExport = findViewById(R.id.button_export);
|
_buttonExport = findViewById(R.id.button_export);
|
||||||
|
_buttonModeration = findViewById(R.id.button_moderation);
|
||||||
_buttonLogout = findViewById(R.id.button_logout);
|
_buttonLogout = findViewById(R.id.button_logout);
|
||||||
_buttonDelete = findViewById(R.id.button_delete);
|
_buttonDelete = findViewById(R.id.button_delete);
|
||||||
_loaderOverlay = findViewById(R.id.loader_overlay);
|
_loaderOverlay = findViewById(R.id.loader_overlay);
|
||||||
_textSystem = findViewById(R.id.text_system)
|
_textSystem = findViewById(R.id.text_system)
|
||||||
|
findViewById<TextView>(R.id.text_cta2).setOnClickListener {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://harbor.social")))
|
||||||
|
}
|
||||||
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
saveIfRequired();
|
saveIfRequired();
|
||||||
finish();
|
finish();
|
||||||
@@ -92,6 +99,10 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
startActivity(Intent(this, PolycentricBackupActivity::class.java));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_buttonModeration.onClick.subscribe {
|
||||||
|
startActivity(Intent(this, PolycentricModerationActivity::class.java));
|
||||||
|
};
|
||||||
|
|
||||||
_buttonLogout.onClick.subscribe {
|
_buttonLogout.onClick.subscribe {
|
||||||
StatePolycentric.instance.setProcessHandle(null);
|
StatePolycentric.instance.setProcessHandle(null);
|
||||||
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
||||||
@@ -108,6 +119,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
StatePolycentric.instance.setProcessHandle(null);
|
StatePolycentric.instance.setProcessHandle(null);
|
||||||
Store.instance.removeProcessSecret(processHandle.system);
|
Store.instance.removeProcessSecret(processHandle.system);
|
||||||
|
PolycentricStorage.instance.removeProcessSecret(processHandle.system);
|
||||||
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
startActivity(Intent(this, PolycentricHomeActivity::class.java));
|
||||||
finish();
|
finish();
|
||||||
});
|
});
|
||||||
@@ -127,7 +139,7 @@ class PolycentricProfileActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
processHandle.fullyBackfillClient(PolycentricCache.SERVER)
|
processHandle.fullyBackfillClient(ApiMethods.SERVER)
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
updateUI();
|
updateUI();
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.MultiFormatWriter
|
||||||
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
|
||||||
|
class QRCodeFullscreenActivity : AppCompatActivity() {
|
||||||
|
companion object {
|
||||||
|
private const val EXTRA_QR_TEXT = "qr_text"
|
||||||
|
|
||||||
|
fun createIntent(context: Context, qrText: String): android.content.Intent {
|
||||||
|
return android.content.Intent(context, QRCodeFullscreenActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_QR_TEXT, qrText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_qr_code_fullscreen)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
val qrText = intent.getStringExtra(EXTRA_QR_TEXT)
|
||||||
|
|
||||||
|
val imageQR = findViewById<ImageView>(R.id.image_qr_fullscreen)
|
||||||
|
val buttonBack = findViewById<ImageButton>(R.id.button_back_fullscreen)
|
||||||
|
val buttonClose = findViewById<ImageButton>(R.id.button_close_fullscreen)
|
||||||
|
|
||||||
|
// Generate QR code bitmap from text
|
||||||
|
qrText?.let { text ->
|
||||||
|
try {
|
||||||
|
if (!isContentSuitableForQRCode(text)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val dimension = TypedValue.applyDimension(
|
||||||
|
TypedValue.COMPLEX_UNIT_DIP, 300f, resources.displayMetrics
|
||||||
|
).toInt()
|
||||||
|
val qrBitmap = generateQRCode(text, dimension, dimension)
|
||||||
|
imageQR.setImageBitmap(qrBitmap)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If QR generation fails, show error or fallback
|
||||||
|
imageQR.setImageResource(R.drawable.ic_qr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonBack.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonClose.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
imageQR.setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isContentSuitableForQRCode(content: String): Boolean {
|
||||||
|
val bytes = content.toByteArray(Charsets.UTF_8)
|
||||||
|
return bytes.size <= 2300 // QR Code Version 40 with Error Correction Level M can hold ~2331 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
|
if (!isContentSuitableForQRCode(content)) {
|
||||||
|
throw Exception("Data too big for QR code generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
val hints = java.util.EnumMap<EncodeHintType, Any>(EncodeHintType::class.java)
|
||||||
|
hints[EncodeHintType.ERROR_CORRECTION] = ErrorCorrectionLevel.M
|
||||||
|
hints[EncodeHintType.MARGIN] = 1
|
||||||
|
|
||||||
|
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height, hints)
|
||||||
|
return bitMatrixToBitmap(bitMatrix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
|
val width = matrix.width
|
||||||
|
val height = matrix.height
|
||||||
|
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
|
||||||
|
|
||||||
|
for (x in 0 until width) {
|
||||||
|
for (y in 0 until height) {
|
||||||
|
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bmp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
package com.futo.platformplayer.activities
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.View
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import android.widget.ImageButton
|
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.activity.result.ActivityResult
|
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.app.ActivityCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import com.futo.platformplayer.*
|
|
||||||
import com.futo.platformplayer.logging.Logger
|
|
||||||
import com.futo.platformplayer.states.StateApp
|
|
||||||
import com.futo.platformplayer.views.LoaderView
|
|
||||||
import com.futo.platformplayer.views.fields.FieldForm
|
|
||||||
import com.futo.platformplayer.views.fields.ReadOnlyTextField
|
|
||||||
import com.google.android.material.button.MaterialButton
|
|
||||||
|
|
||||||
class SettingsActivity : AppCompatActivity(), IWithResultLauncher {
|
|
||||||
private lateinit var _form: FieldForm;
|
|
||||||
private lateinit var _buttonBack: ImageButton;
|
|
||||||
private lateinit var _loaderView: LoaderView;
|
|
||||||
|
|
||||||
private lateinit var _devSets: LinearLayout;
|
|
||||||
private lateinit var _buttonDev: MaterialButton;
|
|
||||||
|
|
||||||
private var _isFinished = false;
|
|
||||||
|
|
||||||
lateinit var overlay: FrameLayout;
|
|
||||||
|
|
||||||
val notifPermission = "android.permission.POST_NOTIFICATIONS";
|
|
||||||
val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
|
|
||||||
if (isGranted)
|
|
||||||
UIDialogs.toast(this, "Notification permission granted");
|
|
||||||
else
|
|
||||||
UIDialogs.toast(this, "Notification permission denied");
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(newBase: Context?) {
|
|
||||||
Logger.i("SettingsActivity", "SettingsActivity.attachBaseContext")
|
|
||||||
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
|
||||||
}
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.activity_settings);
|
|
||||||
setNavigationBarColorAndIcons();
|
|
||||||
|
|
||||||
_form = findViewById(R.id.settings_form);
|
|
||||||
_buttonBack = findViewById(R.id.button_back);
|
|
||||||
_buttonDev = findViewById(R.id.button_dev);
|
|
||||||
_devSets = findViewById(R.id.dev_settings);
|
|
||||||
_loaderView = findViewById(R.id.loader);
|
|
||||||
overlay = findViewById(R.id.overlay_container);
|
|
||||||
|
|
||||||
_form.onChanged.subscribe { field, _ ->
|
|
||||||
Logger.i("SettingsActivity", "Setting [${field.field?.name}] changed, saving");
|
|
||||||
_form.setObjectValues();
|
|
||||||
Settings.instance.save();
|
|
||||||
|
|
||||||
if(field.descriptor?.id == "app_language") {
|
|
||||||
Logger.i("SettingsActivity", "App language change detected, propogating to shared preferences");
|
|
||||||
StateApp.instance.setLocaleSetting(this, Settings.instance.language.getAppLanguageLocaleString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if(field.descriptor?.id == "background_update") {
|
|
||||||
Logger.i("SettingsActivity", "Detected change in background work ${field.value}");
|
|
||||||
if(Settings.instance.subscriptions.subscriptionsBackgroundUpdateInterval > 0) {
|
|
||||||
val notifManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
|
|
||||||
if(!notifManager.areNotificationsEnabled()) {
|
|
||||||
UIDialogs.toast(this, "Notifications aren't enabled");
|
|
||||||
|
|
||||||
when {
|
|
||||||
ContextCompat.checkSelfPermission(this, notifPermission) == PackageManager.PERMISSION_GRANTED -> {
|
|
||||||
|
|
||||||
}
|
|
||||||
ActivityCompat.shouldShowRequestPermissionRationale(this, notifPermission) -> {
|
|
||||||
UIDialogs.showDialog(this, R.drawable.ic_notifications, "Notifications Required",
|
|
||||||
"Notifications need to be enabled for background updating to function", null, 0,
|
|
||||||
UIDialogs.Action("Cancel", {}),
|
|
||||||
UIDialogs.Action("Enable", {
|
|
||||||
requestPermissionLauncher.launch(notifPermission);
|
|
||||||
}, UIDialogs.ActionStyle.PRIMARY));
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
requestPermissionLauncher.launch(notifPermission);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
_buttonBack.setOnClickListener {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
_buttonDev.setOnClickListener {
|
|
||||||
startActivity(Intent(this, DeveloperActivity::class.java));
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastActivity = this;
|
|
||||||
|
|
||||||
reloadSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
var isFirstLoad = true;
|
|
||||||
fun reloadSettings() {
|
|
||||||
val firstLoad = isFirstLoad;
|
|
||||||
isFirstLoad = false;
|
|
||||||
_form.setSearchVisible(false);
|
|
||||||
_loaderView.start();
|
|
||||||
_form.fromObject(lifecycleScope, Settings.instance) {
|
|
||||||
_loaderView.stop();
|
|
||||||
_form.setSearchVisible(true);
|
|
||||||
|
|
||||||
var devCounter = 0;
|
|
||||||
_form.findField("code")?.assume<ReadOnlyTextField>()?.setOnClickListener {
|
|
||||||
devCounter++;
|
|
||||||
if(devCounter > 5) {
|
|
||||||
devCounter = 0;
|
|
||||||
SettingsDev.instance.developerMode = true;
|
|
||||||
SettingsDev.instance.save();
|
|
||||||
updateDevMode();
|
|
||||||
UIDialogs.toast(this, getString(R.string.you_are_now_in_developer_mode));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if(firstLoad) {
|
|
||||||
val query = intent.getStringExtra("query");
|
|
||||||
if(!query.isNullOrEmpty()) {
|
|
||||||
_form.setSearchQuery(query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
updateDevMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateDevMode() {
|
|
||||||
if(SettingsDev.instance.developerMode)
|
|
||||||
_devSets.visibility = View.VISIBLE;
|
|
||||||
else
|
|
||||||
_devSets.visibility = View.GONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun finish() {
|
|
||||||
super.finish()
|
|
||||||
_isFinished = true;
|
|
||||||
if(_lastActivity == this)
|
|
||||||
_lastActivity = null;
|
|
||||||
overridePendingTransition(R.anim.slide_lighten, R.anim.slide_out_up)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private var resultLauncherMap = mutableMapOf<Int, (ActivityResult)->Unit>();
|
|
||||||
private var requestCode: Int? = -1;
|
|
||||||
private val resultLauncher: ActivityResultLauncher<Intent> = registerForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
result: ActivityResult ->
|
|
||||||
val handler = synchronized(resultLauncherMap) {
|
|
||||||
resultLauncherMap.remove(requestCode);
|
|
||||||
}
|
|
||||||
if(handler != null)
|
|
||||||
handler(result);
|
|
||||||
};
|
|
||||||
override fun launchForResult(intent: Intent, code: Int, handler: (ActivityResult)->Unit) {
|
|
||||||
synchronized(resultLauncherMap) {
|
|
||||||
resultLauncherMap[code] = handler;
|
|
||||||
}
|
|
||||||
requestCode = code;
|
|
||||||
resultLauncher.launch(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
//TODO: Temporary for solving Settings issues
|
|
||||||
@SuppressLint("StaticFieldLeak")
|
|
||||||
private var _lastActivity: SettingsActivity? = null;
|
|
||||||
|
|
||||||
fun getActivity(): SettingsActivity? {
|
|
||||||
val act = _lastActivity;
|
|
||||||
if(act != null && !act._isFinished)
|
|
||||||
return act;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
|
import com.futo.platformplayer.sync.internal.LinkType
|
||||||
|
import com.futo.platformplayer.sync.internal.SyncSession
|
||||||
|
import com.futo.platformplayer.views.sync.SyncDeviceView
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncHomeActivity : AppCompatActivity() {
|
||||||
|
private lateinit var _layoutDevices: LinearLayout
|
||||||
|
private lateinit var _layoutEmpty: LinearLayout
|
||||||
|
private val _viewMap: MutableMap<String, SyncDeviceView> = mutableMapOf()
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
if (StateApp.instance.contextOrNull == null) {
|
||||||
|
Logger.w(TAG, "No main activity, restarting main.")
|
||||||
|
val intent = Intent(this, MainActivity::class.java)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_sync_home)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
_layoutDevices = findViewById(R.id.layout_devices)
|
||||||
|
_layoutEmpty = findViewById(R.id.layout_empty)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
|
||||||
|
startActivity(Intent(this@SyncHomeActivity, SyncPairActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<LinearLayout>(R.id.button_show_pairing_code).setOnClickListener {
|
||||||
|
startActivity(Intent(this@SyncHomeActivity, SyncShowPairingCodeActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeDevices()
|
||||||
|
|
||||||
|
StateSync.instance.deviceUpdatedOrAdded.subscribe(this) { publicKey, session ->
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
val view = _viewMap[publicKey]
|
||||||
|
if (!session.isAuthorized) {
|
||||||
|
if (view != null) {
|
||||||
|
_viewMap.remove(publicKey)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view == null) {
|
||||||
|
val syncDeviceView = SyncDeviceView(this@SyncHomeActivity)
|
||||||
|
syncDeviceView.onRemove.subscribe {
|
||||||
|
StateApp.instance.scopeOrNull?.launch {
|
||||||
|
StateSync.instance.delete(publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val v = updateDeviceView(syncDeviceView, publicKey, session)
|
||||||
|
_layoutDevices.addView(v, 0)
|
||||||
|
_viewMap[publicKey] = v
|
||||||
|
} else {
|
||||||
|
updateDeviceView(view, publicKey, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEmptyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateSync.instance.deviceRemoved.subscribe(this) {
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
val view = _viewMap[it]
|
||||||
|
if (view != null) {
|
||||||
|
_layoutDevices.removeView(view)
|
||||||
|
_viewMap.remove(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEmptyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StateSync.instance.confirmStarted(this, onStarted = {
|
||||||
|
if (StateSync.instance.syncService?.serverSocketFailedToStart == true) {
|
||||||
|
UIDialogs.toast(this, "Server socket failed to start, is the port in use?", true)
|
||||||
|
}
|
||||||
|
if (StateSync.instance.syncService?.relayConnected == false) {
|
||||||
|
UIDialogs.toast(this, "Not connected to relay, remote connections will work.", false)
|
||||||
|
}
|
||||||
|
if (StateSync.instance.syncService?.serverSocketStarted == false) {
|
||||||
|
UIDialogs.toast(this, "Listener not started, local connections will not work.", false)
|
||||||
|
}
|
||||||
|
}, onNotStarted = {
|
||||||
|
finish()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
StateSync.instance.deviceUpdatedOrAdded.remove(this)
|
||||||
|
StateSync.instance.deviceRemoved.remove(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDeviceView(syncDeviceView: SyncDeviceView, publicKey: String, session: SyncSession?): SyncDeviceView {
|
||||||
|
val connected = session?.connected ?: false
|
||||||
|
val authorized = session?.isAuthorized ?: false
|
||||||
|
|
||||||
|
syncDeviceView.setLinkType(session?.linkType ?: LinkType.None)
|
||||||
|
.setName(session?.displayName ?: StateSync.instance.getCachedName(publicKey) ?: publicKey)
|
||||||
|
//TODO: also display public key?
|
||||||
|
.setStatus(if (connected && authorized) "Connected" else "Disconnected or unauthorized")
|
||||||
|
return syncDeviceView
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEmptyVisibility() {
|
||||||
|
if (_viewMap.isNotEmpty()) {
|
||||||
|
_layoutEmpty.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
_layoutEmpty.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun initializeDevices() {
|
||||||
|
_layoutDevices.removeAllViews()
|
||||||
|
|
||||||
|
for (publicKey in StateSync.instance.getAll()) {
|
||||||
|
val syncDeviceView = SyncDeviceView(this)
|
||||||
|
syncDeviceView.onRemove.subscribe {
|
||||||
|
StateApp.instance.scopeOrNull?.launch {
|
||||||
|
StateSync.instance.delete(publicKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val view = updateDeviceView(syncDeviceView, publicKey, StateSync.instance.getSession(publicKey))
|
||||||
|
_layoutDevices.addView(view)
|
||||||
|
_viewMap[publicKey] = view
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEmptyVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncHomeActivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
|
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
class SyncPairActivity : AppCompatActivity() {
|
||||||
|
private lateinit var _editCode: EditText
|
||||||
|
|
||||||
|
private lateinit var _layoutPairing: LinearLayout
|
||||||
|
private lateinit var _textPairingStatus: TextView
|
||||||
|
|
||||||
|
private lateinit var _layoutPairingSuccess: LinearLayout
|
||||||
|
|
||||||
|
private lateinit var _layoutPairingError: LinearLayout
|
||||||
|
private lateinit var _textError: TextView
|
||||||
|
|
||||||
|
private val _qrCodeResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
val scanResult = IntentIntegrator.parseActivityResult(result.resultCode, result.data)
|
||||||
|
scanResult?.let {
|
||||||
|
if (it.contents != null) {
|
||||||
|
_editCode.text.clear()
|
||||||
|
_editCode.text.append(it.contents)
|
||||||
|
pair(it.contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_sync_pair)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
_editCode = findViewById(R.id.edit_code)
|
||||||
|
_layoutPairing = findViewById(R.id.layout_pairing)
|
||||||
|
_textPairingStatus = findViewById(R.id.text_pairing_status)
|
||||||
|
_layoutPairingSuccess = findViewById(R.id.layout_pairing_success)
|
||||||
|
_layoutPairingError = findViewById(R.id.layout_pairing_error)
|
||||||
|
_textError = findViewById(R.id.text_error)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<LinearLayout>(R.id.button_scan_qr).setOnClickListener {
|
||||||
|
val integrator = IntentIntegrator(this)
|
||||||
|
integrator.setDesiredBarcodeFormats(IntentIntegrator.QR_CODE)
|
||||||
|
integrator.setPrompt(getString(R.string.scan_a_qr_code))
|
||||||
|
integrator.setOrientationLocked(true);
|
||||||
|
integrator.setCameraId(0)
|
||||||
|
integrator.setBeepEnabled(false)
|
||||||
|
integrator.setBarcodeImageEnabled(true)
|
||||||
|
integrator.setCaptureActivity(QRCaptureActivity::class.java);
|
||||||
|
_qrCodeResultLauncher.launch(integrator.createScanIntent())
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<LinearLayout>(R.id.button_link_new_device).setOnClickListener {
|
||||||
|
pair(_editCode.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
_layoutPairingSuccess.setOnClickListener {
|
||||||
|
_layoutPairingSuccess.visibility = View.GONE
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
_layoutPairingError.setOnClickListener {
|
||||||
|
_layoutPairingError.visibility = View.GONE
|
||||||
|
}
|
||||||
|
_layoutPairingSuccess.visibility = View.GONE
|
||||||
|
_layoutPairingError.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pair(url: String) {
|
||||||
|
try {
|
||||||
|
_layoutPairing.visibility = View.VISIBLE
|
||||||
|
_textPairingStatus.text = "Parsing text..."
|
||||||
|
|
||||||
|
if (!url.startsWith("grayjay://sync/")) {
|
||||||
|
throw Exception("Not a valid URL: $url")
|
||||||
|
}
|
||||||
|
|
||||||
|
val deviceInfo: SyncDeviceInfo = Json.decodeFromString<SyncDeviceInfo>(Base64.decode(url.substring("grayjay://sync/".length), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP).decodeToString())
|
||||||
|
if (StateSync.instance.isAuthorized(deviceInfo.publicKey)) {
|
||||||
|
throw Exception("This device is already paired")
|
||||||
|
}
|
||||||
|
|
||||||
|
_textPairingStatus.text = "Connecting..."
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
var wasCompleted = false
|
||||||
|
|
||||||
|
StateSync.instance.syncService?.connect(deviceInfo, true) { complete, message ->
|
||||||
|
if (wasCompleted) {
|
||||||
|
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message} ignored because wasCompleted')")
|
||||||
|
return@connect
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete == true) {
|
||||||
|
wasCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.i(TAG, "onStatusUpdate(complete = ${complete}, message = '${message}')")
|
||||||
|
lifecycleScope.launch(Dispatchers.Main) {
|
||||||
|
if (complete != null) {
|
||||||
|
if (complete) {
|
||||||
|
_layoutPairingSuccess.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
_textError.text = message
|
||||||
|
_layoutPairingError.visibility = View.VISIBLE
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_textPairingStatus.text = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
_layoutPairingError.visibility = View.VISIBLE
|
||||||
|
if(e.message == "Failed to connect") {
|
||||||
|
_textError.text = "Failed to connect.\n\nThis may be due to not being on the same network, due to firewall, or vpn.\nSync currently operates only over local direct connections."
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_textError.text = e.message
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
Logger.e(TAG, "Failed to pair", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e: Throwable) {
|
||||||
|
_layoutPairingError.visibility = View.VISIBLE
|
||||||
|
_textError.text = e.message
|
||||||
|
_layoutPairing.visibility = View.GONE
|
||||||
|
Logger.e(TAG, "Failed to pair", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncPairActivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.futo.platformplayer.activities
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.UIDialogs
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.setNavigationBarColorAndIcons
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.states.StateSync
|
||||||
|
import com.futo.platformplayer.sync.internal.SyncDeviceInfo
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.MultiFormatWriter
|
||||||
|
import com.google.zxing.common.BitMatrix
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
|
class SyncShowPairingCodeActivity : AppCompatActivity() {
|
||||||
|
private lateinit var _textCode: TextView
|
||||||
|
private lateinit var _imageQR: ImageView
|
||||||
|
private lateinit var _textQR: TextView
|
||||||
|
private var _code: String? = null
|
||||||
|
|
||||||
|
override fun attachBaseContext(newBase: Context?) {
|
||||||
|
super.attachBaseContext(StateApp.instance.getLocaleContext(newBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
activity = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
activity = this
|
||||||
|
|
||||||
|
setContentView(R.layout.activity_sync_show_pairing_code)
|
||||||
|
setNavigationBarColorAndIcons()
|
||||||
|
|
||||||
|
_textCode = findViewById(R.id.text_code)
|
||||||
|
_imageQR = findViewById(R.id.image_qr)
|
||||||
|
_textQR = findViewById(R.id.text_scan_qr)
|
||||||
|
|
||||||
|
findViewById<ImageButton>(R.id.button_back).setOnClickListener {
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
findViewById<LinearLayout>(R.id.button_copy).setOnClickListener {
|
||||||
|
val code = _code ?: return@setOnClickListener
|
||||||
|
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager;
|
||||||
|
val clip = ClipData.newPlainText(getString(R.string.copied_text), code);
|
||||||
|
clipboard.setPrimaryClip(clip);
|
||||||
|
UIDialogs.toast(this, "Copied to clipboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
val ips = getIPs()
|
||||||
|
val publicKey = StateSync.instance.syncService?.publicKey
|
||||||
|
val pairingCode = StateSync.instance.syncService?.pairingCode
|
||||||
|
if (publicKey == null || pairingCode == null) {
|
||||||
|
setCode("Public key or pairing code was not known, is sync enabled?")
|
||||||
|
} else {
|
||||||
|
val selfDeviceInfo = SyncDeviceInfo(publicKey, ips.toTypedArray(), StateSync.PORT, pairingCode)
|
||||||
|
val json = Json.encodeToString(selfDeviceInfo)
|
||||||
|
val base64 = Base64.encodeToString(json.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
|
||||||
|
val url = "grayjay://sync/${base64}"
|
||||||
|
setCode(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCode(code: String?) {
|
||||||
|
_code = code
|
||||||
|
|
||||||
|
_textCode.text = code
|
||||||
|
|
||||||
|
if (code == null) {
|
||||||
|
_imageQR.visibility = View.INVISIBLE
|
||||||
|
_textQR.visibility = View.INVISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val dimension = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 200f, resources.displayMetrics).toInt()
|
||||||
|
val qrCodeBitmap = generateQRCode(code, dimension, dimension)
|
||||||
|
_imageQR.setImageBitmap(qrCodeBitmap)
|
||||||
|
_imageQR.visibility = View.VISIBLE
|
||||||
|
_textQR.visibility = View.VISIBLE
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, getString(R.string.failed_to_generate_qr_code), e)
|
||||||
|
_imageQR.visibility = View.INVISIBLE
|
||||||
|
_textQR.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateQRCode(content: String, width: Int, height: Int): Bitmap {
|
||||||
|
val bitMatrix = MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, width, height);
|
||||||
|
return bitMatrixToBitmap(bitMatrix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
|
||||||
|
val width = matrix.width;
|
||||||
|
val height = matrix.height;
|
||||||
|
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
|
||||||
|
|
||||||
|
for (x in 0 until width) {
|
||||||
|
for (y in 0 until height) {
|
||||||
|
bmp.setPixel(x, y, if (matrix[x, y]) Color.BLACK else Color.WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getIPs(): List<String> {
|
||||||
|
val ips = arrayListOf<String>()
|
||||||
|
for (intf in NetworkInterface.getNetworkInterfaces()) {
|
||||||
|
for (addr in intf.inetAddresses) {
|
||||||
|
if (addr.isLoopbackAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addr.address.size != 4) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
addr.hostAddress?.let { ips.add(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncShowPairingCodeActivity"
|
||||||
|
var activity: SyncShowPairingCodeActivity? = null
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,24 @@ package com.futo.platformplayer.activities
|
|||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.futo.platformplayer.R
|
import com.futo.platformplayer.R
|
||||||
|
import com.futo.platformplayer.views.TargetTapLoaderView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class TestActivity : AppCompatActivity() {
|
class TestActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.activity_test);
|
setContentView(R.layout.activity_test);
|
||||||
|
|
||||||
|
val view = findViewById<TargetTapLoaderView>(R.id.test_view)
|
||||||
|
view.startLoader(10000)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
delay(5000)
|
||||||
|
view.startLoader()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import com.futo.platformplayer.SettingsDev
|
|||||||
import com.futo.platformplayer.constructs.Event1
|
import com.futo.platformplayer.constructs.Event1
|
||||||
import com.futo.platformplayer.ensureNotMainThread
|
import com.futo.platformplayer.ensureNotMainThread
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import com.futo.platformplayer.states.StateApp
|
||||||
|
import com.futo.platformplayer.stores.FragmentedStorage
|
||||||
import okhttp3.Call
|
import okhttp3.Call
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -17,13 +19,15 @@ import okhttp3.WebSocket
|
|||||||
import okhttp3.WebSocketListener
|
import okhttp3.WebSocketListener
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
|
import java.time.Duration
|
||||||
import javax.net.ssl.SSLContext
|
import javax.net.ssl.SSLContext
|
||||||
import javax.net.ssl.TrustManager
|
import javax.net.ssl.TrustManager
|
||||||
import javax.net.ssl.X509TrustManager
|
import javax.net.ssl.X509TrustManager
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequestModifier
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
open class ManagedHttpClient {
|
open class ManagedHttpClient {
|
||||||
protected val _builderTemplate: OkHttpClient.Builder;
|
protected var _builderTemplate: OkHttpClient.Builder;
|
||||||
|
|
||||||
private var client: OkHttpClient;
|
private var client: OkHttpClient;
|
||||||
|
|
||||||
@@ -32,6 +36,15 @@ open class ManagedHttpClient {
|
|||||||
|
|
||||||
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
var user_agent = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"
|
||||||
|
|
||||||
|
fun setTimeout(timeout: Long) {
|
||||||
|
rebuildClient {
|
||||||
|
it.callTimeout(Duration.ofMillis(client.callTimeoutMillis.toLong()))
|
||||||
|
.writeTimeout(Duration.ofMillis(client.writeTimeoutMillis.toLong()))
|
||||||
|
.readTimeout(Duration.ofMillis(client.readTimeoutMillis.toLong()))
|
||||||
|
.connectTimeout(Duration.ofMillis(timeout));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val trustAllCerts = arrayOf<TrustManager>(
|
private val trustAllCerts = arrayOf<TrustManager>(
|
||||||
object: X509TrustManager {
|
object: X509TrustManager {
|
||||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) { }
|
||||||
@@ -53,7 +66,7 @@ open class ManagedHttpClient {
|
|||||||
|
|
||||||
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
constructor(builder: OkHttpClient.Builder = OkHttpClient.Builder()) {
|
||||||
_builderTemplate = builder;
|
_builderTemplate = builder;
|
||||||
if(SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
if(FragmentedStorage.isInitialized && StateApp.instance.isMainActive && SettingsDev.instance.developerMode && SettingsDev.instance.networking.allowAllCertificates)
|
||||||
trustAllCertificates(builder);
|
trustAllCertificates(builder);
|
||||||
client = builder.addNetworkInterceptor { chain ->
|
client = builder.addNetworkInterceptor { chain ->
|
||||||
val request = beforeRequest(chain.request());
|
val request = beforeRequest(chain.request());
|
||||||
@@ -62,15 +75,31 @@ open class ManagedHttpClient {
|
|||||||
}.build();
|
}.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun rebuildClient(modify: (OkHttpClient.Builder) -> OkHttpClient.Builder) {
|
||||||
|
_builderTemplate = modify(_builderTemplate);
|
||||||
|
client = _builderTemplate.addNetworkInterceptor { chain ->
|
||||||
|
val request = beforeRequest(chain.request());
|
||||||
|
val response = afterRequest(chain.proceed(request));
|
||||||
|
return@addNetworkInterceptor response;
|
||||||
|
}.build();
|
||||||
|
}
|
||||||
|
|
||||||
open fun clone(): ManagedHttpClient {
|
open fun clone(): ManagedHttpClient {
|
||||||
val clonedClient = ManagedHttpClient(_builderTemplate);
|
val clonedClient = ManagedHttpClient(_builderTemplate);
|
||||||
clonedClient.user_agent = user_agent;
|
clonedClient.user_agent = user_agent;
|
||||||
return clonedClient;
|
return clonedClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tryHead(url: String): Map<String, String>? {
|
private fun applyModifier(url: String, headers: MutableMap<String, String>, modifier: IRequestModifier?): Pair<String, MutableMap<String, String>> {
|
||||||
|
if (modifier == null) return Pair(url, headers)
|
||||||
|
val modified = modifier.modifyRequest(url, headers)
|
||||||
|
return Pair(modified.url ?: url, modified.headers.toMutableMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryHead(url: String, modifier: IRequestModifier? = null): Map<String, String>? {
|
||||||
|
ensureNotMainThread()
|
||||||
try {
|
try {
|
||||||
val result = head(url);
|
val result = head(url, HashMap(), modifier);
|
||||||
if(result.isOk)
|
if(result.isOk)
|
||||||
return result.getHeadersFlat();
|
return result.getHeadersFlat();
|
||||||
else
|
else
|
||||||
@@ -83,7 +112,7 @@ open class ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
fun socket(url: String, headers: MutableMap<String, String> = HashMap(), listener: SocketListener): Socket {
|
||||||
|
ensureNotMainThread()
|
||||||
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder()
|
||||||
.url(url);
|
.url(url);
|
||||||
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
if(user_agent.isNotEmpty() && !headers.any { it.key.lowercase() == "user-agent" })
|
||||||
@@ -119,12 +148,14 @@ open class ManagedHttpClient {
|
|||||||
return Socket(websocket);
|
return Socket(websocket);
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun get(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "GET", null, headers));
|
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||||
|
return execute(Request(finalUrl, "GET", null, finalHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun head(url : String, headers : MutableMap<String, String> = HashMap<String, String>(), modifier: IRequestModifier? = null) : Response {
|
||||||
return execute(Request(url, "HEAD", null, headers));
|
val (finalUrl, finalHeaders) = applyModifier(url, headers, modifier)
|
||||||
|
return execute(Request(finalUrl, "HEAD", null, finalHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
fun post(url : String, headers : MutableMap<String, String> = HashMap<String, String>()) : Response {
|
||||||
@@ -279,6 +310,7 @@ open class ManagedHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun send(msg: String) {
|
fun send(msg: String) {
|
||||||
|
ensureNotMainThread()
|
||||||
socket.send(msg);
|
socket.send(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,20 @@ class HttpContext : AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fun respondBytes(status: Int, headers: HttpHeaders, body: ByteArray? = null) {
|
||||||
|
if(headers.get("content-length").isNullOrEmpty()) {
|
||||||
|
if (body != null) {
|
||||||
|
headers.put("content-length", body.size.toString());
|
||||||
|
} else {
|
||||||
|
headers.put("content-length", "0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respond(status, headers) { responseStream ->
|
||||||
|
if(body != null) {
|
||||||
|
responseStream.write(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) {
|
fun respond(status: Int, headers: HttpHeaders, writing: (OutputStream)->Unit) {
|
||||||
val responseStream = _responseStream ?: throw IllegalStateException("No response stream set");
|
val responseStream = _responseStream ?: throw IllegalStateException("No response stream set");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
import com.futo.platformplayer.api.http.server.exceptions.EmptyRequestException
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpFuntionHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpFunctionHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpHandler
|
||||||
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
import com.futo.platformplayer.api.http.server.handlers.HttpOptionsAllowHandler
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -208,20 +208,20 @@ class ManagedHttpServer(private val _requestedPort: Int = 0) {
|
|||||||
|
|
||||||
for(getMethod in getMethods)
|
for(getMethod in getMethods)
|
||||||
if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1)
|
if(getMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && getMethod.first.parameterCount == 1)
|
||||||
addHandler(HttpFuntionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
addHandler(HttpFunctionHandler("GET", getMethod.second.path) { getMethod.first.invoke(obj, it) }).apply {
|
||||||
if(!getMethod.second.contentType.isEmpty())
|
if(!getMethod.second.contentType.isEmpty())
|
||||||
this.withContentType(getMethod.second.contentType);
|
this.withContentType(getMethod.second.contentType);
|
||||||
}.withContentType(getMethod.second.contentType);
|
}.withContentType(getMethod.second.contentType);
|
||||||
for(postMethod in postMethods)
|
for(postMethod in postMethods)
|
||||||
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
if(postMethod.first.parameterTypes.firstOrNull() == HttpContext::class.java && postMethod.first.parameterCount == 1)
|
||||||
addHandler(HttpFuntionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
addHandler(HttpFunctionHandler("POST", postMethod.second.path) { postMethod.first.invoke(obj, it) }).apply {
|
||||||
if(!postMethod.second.contentType.isEmpty())
|
if(!postMethod.second.contentType.isEmpty())
|
||||||
this.withContentType(postMethod.second.contentType);
|
this.withContentType(postMethod.second.contentType);
|
||||||
}.withContentType(postMethod.second.contentType);
|
}.withContentType(postMethod.second.contentType);
|
||||||
|
|
||||||
for(getField in getFields) {
|
for(getField in getFields) {
|
||||||
getField.first.isAccessible = true;
|
getField.first.isAccessible = true;
|
||||||
addHandler(HttpFuntionHandler("GET", getField.second.path) {
|
addHandler(HttpFunctionHandler("GET", getField.second.path) {
|
||||||
val value = getField.first.get(obj) as String?;
|
val value = getField.first.get(obj) as String?;
|
||||||
if(value != null) {
|
if(value != null) {
|
||||||
val headers = HttpHeaders(
|
val headers = HttpHeaders(
|
||||||
|
|||||||
+318
@@ -0,0 +1,318 @@
|
|||||||
|
package com.futo.platformplayer.api.http.server.handlers
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.provider.OpenableColumns
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class HttpContentUriHandler(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val explicitContentType: String? = null
|
||||||
|
) : HttpHandler(method, path) {
|
||||||
|
|
||||||
|
override fun handle(httpContext: HttpContext) {
|
||||||
|
val resolver = contentResolver
|
||||||
|
val requestHeaders = httpContext.headers
|
||||||
|
val responseHeaders = this.headers.clone()
|
||||||
|
|
||||||
|
val meta = try {
|
||||||
|
queryMetadata(resolver, uri)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to query metadata for $uri", e)
|
||||||
|
httpContext.respondCode(404, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentType = explicitContentType
|
||||||
|
?: resolver.getType(uri)
|
||||||
|
?: "application/octet-stream"
|
||||||
|
responseHeaders["Content-Type"] = contentType
|
||||||
|
|
||||||
|
meta.lastModifiedMillis?.let { lastModified ->
|
||||||
|
responseHeaders["Last-Modified"] = httpDateFormat.format(Date(lastModified))
|
||||||
|
|
||||||
|
val ifModifiedSinceHeader = requestHeaders["If-Modified-Since"]
|
||||||
|
if (ifModifiedSinceHeader != null) {
|
||||||
|
val ifModifiedSince = try {
|
||||||
|
httpDateFormat.parse(ifModifiedSinceHeader)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ifModifiedSince != null && lastModified <= ifModifiedSince.time) {
|
||||||
|
httpContext.respondCode(304, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeName = (meta.displayName ?: "content.bin").replace("\"", "\\\"")
|
||||||
|
responseHeaders["Content-Disposition"] = "attachment; filename=\"$safeName\""
|
||||||
|
|
||||||
|
val length = meta.size
|
||||||
|
if (length == null) {
|
||||||
|
Logger.i(TAG, "Streaming $uri with unknown length; Range not supported")
|
||||||
|
responseHeaders.remove("Content-Length")
|
||||||
|
responseHeaders.remove("Content-Range")
|
||||||
|
responseHeaders.remove("Accept-Ranges")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = null,
|
||||||
|
length = null
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
responseHeaders["Accept-Ranges"] = "bytes"
|
||||||
|
|
||||||
|
val rangeHeader = requestHeaders["Range"]
|
||||||
|
if (rangeHeader.isNullOrBlank()) {
|
||||||
|
responseHeaders["Content-Length"] = length.toString()
|
||||||
|
Logger.i(TAG, "Sending full content for $uri, length=$length")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 200,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = 0L,
|
||||||
|
length = length
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val range = parseRange(rangeHeader, length)
|
||||||
|
if (range == null) {
|
||||||
|
Logger.w(TAG, "Invalid Range '$rangeHeader' for $uri (length=$length)")
|
||||||
|
responseHeaders["Content-Range"] = "bytes */$length"
|
||||||
|
httpContext.respondCode(416, responseHeaders)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val start = range.first
|
||||||
|
val endInclusive = range.last
|
||||||
|
val bytesToSend = endInclusive - start + 1
|
||||||
|
|
||||||
|
responseHeaders["Content-Range"] = "bytes $start-$endInclusive/$length"
|
||||||
|
responseHeaders["Content-Length"] = bytesToSend.toString()
|
||||||
|
Logger.i(TAG, "Sending range $start-$endInclusive (length=$bytesToSend) of $length for $uri")
|
||||||
|
|
||||||
|
stream(
|
||||||
|
httpContext = httpContext,
|
||||||
|
resolver = resolver,
|
||||||
|
uri = uri,
|
||||||
|
statusCode = 206,
|
||||||
|
headers = responseHeaders,
|
||||||
|
start = start,
|
||||||
|
length = bytesToSend
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ContentMeta(
|
||||||
|
val displayName: String?,
|
||||||
|
val size: Long?,
|
||||||
|
val lastModifiedMillis: Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun queryMetadata(resolver: ContentResolver, uri: Uri): ContentMeta {
|
||||||
|
var displayName: String? = null
|
||||||
|
var size: Long? = null
|
||||||
|
var lastModifiedMillis: Long? = null
|
||||||
|
|
||||||
|
resolver.query(uri, null, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||||
|
if (nameIndex != -1 && !cursor.isNull(nameIndex)) {
|
||||||
|
displayName = cursor.getString(nameIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||||
|
if (sizeIndex != -1 && !cursor.isNull(sizeIndex)) {
|
||||||
|
val s = cursor.getLong(sizeIndex)
|
||||||
|
if (s >= 0) size = s // -1 means unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateModifiedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
|
||||||
|
if (dateModifiedIndex != -1 && !cursor.isNull(dateModifiedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateModifiedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastModifiedMillis == null) {
|
||||||
|
val dateAddedIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)
|
||||||
|
if (dateAddedIndex != -1 && !cursor.isNull(dateAddedIndex)) {
|
||||||
|
val seconds = cursor.getLong(dateAddedIndex)
|
||||||
|
if (seconds > 0) {
|
||||||
|
lastModifiedMillis = seconds * 1000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayName == null) {
|
||||||
|
displayName = uri.lastPathSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
if (size == null) {
|
||||||
|
try {
|
||||||
|
resolver.openAssetFileDescriptor(uri, "r")?.use { afd ->
|
||||||
|
val assetLen = afd.length
|
||||||
|
if (assetLen >= 0) {
|
||||||
|
size = assetLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
return ContentMeta(
|
||||||
|
displayName = displayName,
|
||||||
|
size = size,
|
||||||
|
lastModifiedMillis = lastModifiedMillis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRange(header: String, totalLength: Long): LongRange? {
|
||||||
|
if (totalLength <= 0L) return null
|
||||||
|
|
||||||
|
val prefix = "bytes="
|
||||||
|
if (!header.startsWith(prefix, ignoreCase = true)) return null
|
||||||
|
|
||||||
|
val spec = header.substring(prefix.length).trim()
|
||||||
|
if (spec.isEmpty()) return null
|
||||||
|
|
||||||
|
if (spec.contains(",")) return null
|
||||||
|
|
||||||
|
val dashIndex = spec.indexOf('-')
|
||||||
|
if (dashIndex < 0) return null
|
||||||
|
|
||||||
|
val startPart = spec.substring(0, dashIndex).trim()
|
||||||
|
val endPart = spec.substring(dashIndex + 1).trim()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
startPart.isNotEmpty() -> {
|
||||||
|
val start = startPart.toLongOrNull() ?: return null
|
||||||
|
if (start < 0 || start >= totalLength) return null
|
||||||
|
|
||||||
|
val end = if (endPart.isNotEmpty()) {
|
||||||
|
val rawEnd = endPart.toLongOrNull() ?: return null
|
||||||
|
if (rawEnd < start) return null
|
||||||
|
rawEnd.coerceAtMost(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
totalLength - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
|
||||||
|
endPart.isNotEmpty() -> {
|
||||||
|
val suffixLen = endPart.toLongOrNull() ?: return null
|
||||||
|
if (suffixLen <= 0L) return null
|
||||||
|
|
||||||
|
if (suffixLen >= totalLength) {
|
||||||
|
0L..(totalLength - 1)
|
||||||
|
} else {
|
||||||
|
val start = totalLength - suffixLen
|
||||||
|
val end = totalLength - 1
|
||||||
|
start..end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stream(httpContext: HttpContext, resolver: ContentResolver, uri: Uri, statusCode: Int, headers: HttpHeaders, start: Long?, length: Long?) {
|
||||||
|
try {
|
||||||
|
val input = resolver.openInputStream(uri)
|
||||||
|
if (input == null) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri")
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.use { inputStream ->
|
||||||
|
httpContext.respond(statusCode, headers) { outputStream ->
|
||||||
|
try {
|
||||||
|
val offset = start ?: 0L
|
||||||
|
if (offset > 0L) {
|
||||||
|
skipFully(inputStream, offset)
|
||||||
|
}
|
||||||
|
copyStream(inputStream, outputStream, length)
|
||||||
|
outputStream.flush()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Error while streaming $uri (start=$start, length=$length)", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: FileNotFoundException) {
|
||||||
|
Logger.w(TAG, "Content not found: $uri", e)
|
||||||
|
httpContext.respondCode(404, headers)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Logger.e(TAG, "Failed to open stream for $uri", e)
|
||||||
|
httpContext.respondCode(500, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyStream(input: InputStream, output: OutputStream, limit: Long?) {
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
if (limit == null) {
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var remaining = limit
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val toRead = remaining.coerceAtMost(buffer.size.toLong()).toInt()
|
||||||
|
val read = input.read(buffer, 0, toRead)
|
||||||
|
if (read < 0) break
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
remaining -= read.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipFully(input: InputStream, bytesToSkip: Long) {
|
||||||
|
var remaining = bytesToSkip
|
||||||
|
while (remaining > 0L) {
|
||||||
|
val skipped = input.skip(remaining)
|
||||||
|
if (skipped <= 0L) {
|
||||||
|
val b = input.read()
|
||||||
|
if (b == -1) break
|
||||||
|
remaining -= 1L
|
||||||
|
} else {
|
||||||
|
remaining -= skipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HttpContentUriHandler"
|
||||||
|
|
||||||
|
private val httpDateFormat = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("GMT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -73,7 +73,7 @@ class HttpFileHandler(method: String, path: String, private val contentType: Str
|
|||||||
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
|
Logger.v(TAG, "Sent bytes $current-${current + bytesToSend}, totalBytesSent=$totalBytesSent")
|
||||||
|
|
||||||
current += bytesToSend.toLong()
|
current += bytesToSend.toLong()
|
||||||
if (current >= end) {
|
if (current > end) {
|
||||||
Logger.i(TAG, "Expected amount of bytes sent")
|
Logger.i(TAG, "Expected amount of bytes sent")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ package com.futo.platformplayer.api.http.server.handlers
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
|
|
||||||
class HttpFuntionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
class HttpFunctionHandler(method: String, path: String, val handler: (HttpContext)->Unit) : HttpHandler(method, path) {
|
||||||
override fun handle(httpContext: HttpContext) {
|
override fun handle(httpContext: HttpContext) {
|
||||||
httpContext.setResponseHeaders(this.headers);
|
httpContext.setResponseHeaders(this.headers);
|
||||||
handler(httpContext);
|
handler(httpContext);
|
||||||
|
|||||||
+39
-9
@@ -5,6 +5,7 @@ import android.util.Log
|
|||||||
import com.futo.platformplayer.api.http.server.HttpContext
|
import com.futo.platformplayer.api.http.server.HttpContext
|
||||||
import com.futo.platformplayer.api.http.server.HttpHeaders
|
import com.futo.platformplayer.api.http.server.HttpHeaders
|
||||||
import com.futo.platformplayer.api.http.ManagedHttpClient
|
import com.futo.platformplayer.api.http.ManagedHttpClient
|
||||||
|
import com.futo.platformplayer.api.media.models.modifier.IRequest
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
import com.futo.platformplayer.parsers.HttpResponseParser
|
import com.futo.platformplayer.parsers.HttpResponseParser
|
||||||
import com.futo.platformplayer.readLine
|
import com.futo.platformplayer.readLine
|
||||||
@@ -27,6 +28,7 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
private var _injectReferer = false;
|
private var _injectReferer = false;
|
||||||
|
|
||||||
private val _client = ManagedHttpClient();
|
private val _client = ManagedHttpClient();
|
||||||
|
private var _requestModifier: ((String, Map<String, String>) -> IRequest)? = null;
|
||||||
|
|
||||||
override fun handle(context: HttpContext) {
|
override fun handle(context: HttpContext) {
|
||||||
if (useTcp) {
|
if (useTcp) {
|
||||||
@@ -43,21 +45,33 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
for (injectHeader in _injectRequestHeader)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||||
|
|
||||||
val parsed = Uri.parse(targetUrl);
|
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||||
|
var url = targetUrl
|
||||||
|
if (req != null) {
|
||||||
|
req.url?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
req.headers.let {
|
||||||
|
proxyHeaders.clear()
|
||||||
|
proxyHeaders.putAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = Uri.parse(url);
|
||||||
if(_injectHost)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${targetUrl}");
|
Logger.i(TAG, "handleWithOkHttp Proxied Request ${useMethod}: ${url}");
|
||||||
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
Logger.i(TAG, "handleWithOkHttp Headers:" + proxyHeaders.map { "${it.key}: ${it.value}" }.joinToString("\n"));
|
||||||
|
|
||||||
val resp = when (useMethod) {
|
val resp = when (useMethod) {
|
||||||
"GET" -> _client.get(targetUrl, proxyHeaders);
|
"GET" -> _client.get(url, proxyHeaders);
|
||||||
"POST" -> _client.post(targetUrl, content ?: "", proxyHeaders);
|
"POST" -> _client.post(url, content ?: "", proxyHeaders);
|
||||||
"HEAD" -> _client.head(targetUrl, proxyHeaders)
|
"HEAD" -> _client.head(url, proxyHeaders)
|
||||||
else -> _client.requestMethod(useMethod, targetUrl, proxyHeaders);
|
else -> _client.requestMethod(useMethod, url, proxyHeaders);
|
||||||
};
|
};
|
||||||
|
|
||||||
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
Logger.i(TAG, "Proxied Response [${resp.code}]");
|
||||||
@@ -91,11 +105,23 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
for (injectHeader in _injectRequestHeader)
|
for (injectHeader in _injectRequestHeader)
|
||||||
proxyHeaders[injectHeader.first] = injectHeader.second;
|
proxyHeaders[injectHeader.first] = injectHeader.second;
|
||||||
|
|
||||||
val parsed = Uri.parse(targetUrl);
|
val req = _requestModifier?.invoke(targetUrl, proxyHeaders)
|
||||||
|
var url = targetUrl
|
||||||
|
if (req != null) {
|
||||||
|
req.url?.let {
|
||||||
|
url = it
|
||||||
|
}
|
||||||
|
req.headers.let {
|
||||||
|
proxyHeaders.clear()
|
||||||
|
proxyHeaders.putAll(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val parsed = Uri.parse(url);
|
||||||
if(_injectHost)
|
if(_injectHost)
|
||||||
proxyHeaders.put("Host", parsed.host!!);
|
proxyHeaders.put("Host", parsed.host!!);
|
||||||
if(_injectReferer)
|
if(_injectReferer)
|
||||||
proxyHeaders.put("Referer", targetUrl);
|
proxyHeaders.put("Referer", url);
|
||||||
|
|
||||||
val useMethod = if (method == "inherit") context.method else method;
|
val useMethod = if (method == "inherit") context.method else method;
|
||||||
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
Logger.i(TAG, "handleWithTcp Proxied Request ${useMethod}: ${parsed}");
|
||||||
@@ -242,6 +268,10 @@ class HttpProxyHandler(method: String, path: String, val targetUrl: String, priv
|
|||||||
_ignoreRequestHeaders.add("referer");
|
_ignoreRequestHeaders.add("referer");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
fun withRequestModifier(modifier: (String, Map<String, String>) -> IRequest) : HttpProxyHandler {
|
||||||
|
_requestModifier = modifier;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HttpProxyHandler"
|
private const val TAG = "HttpProxyHandler"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.futo.platformplayer.api.media
|
package com.futo.platformplayer.api.media
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.IPlatformChannelContent
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
import com.futo.platformplayer.api.media.models.ResultCapabilities
|
||||||
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
|
||||||
@@ -12,6 +13,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||||||
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
import com.futo.platformplayer.api.media.models.playback.IPlaybackTracker
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylist
|
||||||
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
import com.futo.platformplayer.api.media.models.playlists.IPlatformPlaylistDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.models.ImageVariable
|
import com.futo.platformplayer.models.ImageVariable
|
||||||
|
|
||||||
@@ -35,6 +37,11 @@ interface IPlatformClient {
|
|||||||
*/
|
*/
|
||||||
fun getHome(): IPager<IPlatformContent>
|
fun getHome(): IPager<IPlatformContent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the shorts feed
|
||||||
|
*/
|
||||||
|
fun getShorts(): IPager<IPlatformVideo>
|
||||||
|
|
||||||
//Search
|
//Search
|
||||||
/**
|
/**
|
||||||
* Gets search suggestion for the provided query string
|
* Gets search suggestion for the provided query string
|
||||||
@@ -66,6 +73,11 @@ interface IPlatformClient {
|
|||||||
*/
|
*/
|
||||||
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
fun searchChannels(query: String): IPager<PlatformAuthorLink>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for channels and returns a content pager
|
||||||
|
*/
|
||||||
|
fun searchChannelsAsContent(query: String): IPager<IPlatformContent>;
|
||||||
|
|
||||||
|
|
||||||
//Video Pages
|
//Video Pages
|
||||||
/**
|
/**
|
||||||
@@ -170,6 +182,10 @@ interface IPlatformClient {
|
|||||||
* Retrieves the subscriptions of the currently logged in user
|
* Retrieves the subscriptions of the currently logged in user
|
||||||
*/
|
*/
|
||||||
fun getUserSubscriptions(): Array<String>;
|
fun getUserSubscriptions(): Array<String>;
|
||||||
|
/**
|
||||||
|
* Retrieves the history of the currently logged in user
|
||||||
|
*/
|
||||||
|
fun getUserHistory(): IPager<IPlatformContent>;
|
||||||
|
|
||||||
|
|
||||||
fun isClaimTypeSupported(claimType: Int): Boolean;
|
fun isClaimTypeSupported(claimType: Int): Boolean;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.futo.platformplayer.api.media.models.live.IPlatformLiveEvent
|
|||||||
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
import com.futo.platformplayer.api.media.models.live.LiveEventComment
|
||||||
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
import com.futo.platformplayer.api.media.models.live.LiveEventEmojis
|
||||||
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
import com.futo.platformplayer.api.media.platforms.js.models.JSLiveEventPager
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSVODEventPager
|
||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
import com.futo.platformplayer.constructs.BatchedTaskHandler
|
||||||
import com.futo.platformplayer.logging.Logger
|
import com.futo.platformplayer.logging.Logger
|
||||||
@@ -26,12 +27,17 @@ class LiveChatManager {
|
|||||||
private val _emojiCache: EmojiCache = EmojiCache();
|
private val _emojiCache: EmojiCache = EmojiCache();
|
||||||
private val _pager: IPager<IPlatformLiveEvent>?;
|
private val _pager: IPager<IPlatformLiveEvent>?;
|
||||||
|
|
||||||
|
private var _position: Long = 0;
|
||||||
|
private var _eventsPosition: Long = 0;
|
||||||
|
|
||||||
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
private val _history: ArrayList<IPlatformLiveEvent> = arrayListOf();
|
||||||
|
|
||||||
private var _startCounter = 0;
|
private var _startCounter = 0;
|
||||||
|
|
||||||
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
private val _followers: HashMap<Any, (List<IPlatformLiveEvent>) -> Unit> = hashMapOf();
|
||||||
|
|
||||||
|
val isVOD get() = _pager is JSVODEventPager;
|
||||||
|
|
||||||
var viewCount: Long = 0
|
var viewCount: Long = 0
|
||||||
private set;
|
private set;
|
||||||
|
|
||||||
@@ -39,8 +45,24 @@ class LiveChatManager {
|
|||||||
_scope = scope;
|
_scope = scope;
|
||||||
_pager = pager;
|
_pager = pager;
|
||||||
viewCount = initialViewCount;
|
viewCount = initialViewCount;
|
||||||
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
if(pager is JSVODEventPager)
|
||||||
handleEvents(pager.getResults());
|
handleEvents(listOf(LiveEventComment("SYSTEM", null, "VOD chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||||
|
else
|
||||||
|
handleEvents(listOf(LiveEventComment("SYSTEM", null, "Live chat is still under construction. While it is mostly functional, the experience still needs to be improved.\n")));
|
||||||
|
|
||||||
|
if(pager is JSVODEventPager) {
|
||||||
|
var replayResults = pager.getResults().filter { it.time > _eventsPosition || it is LiveEventEmojis };
|
||||||
|
//TODO: Remove this once dripfeed is done properly
|
||||||
|
replayResults = replayResults.filter{ it.time < _eventsPosition + 1500 || it is LiveEventEmojis };
|
||||||
|
if(replayResults.size > 0) {
|
||||||
|
_eventsPosition = replayResults.maxOf { it.time };
|
||||||
|
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_eventsPosition = _eventsPosition + 1500;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
handleEvents(pager.getResults());
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
@@ -52,6 +74,10 @@ class LiveChatManager {
|
|||||||
_startCounter++;
|
_startCounter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVideoPosition(ms: Long) {
|
||||||
|
_position = ms;
|
||||||
|
}
|
||||||
|
|
||||||
fun getHistory(): List<IPlatformLiveEvent> {
|
fun getHistory(): List<IPlatformLiveEvent> {
|
||||||
synchronized(_history) {
|
synchronized(_history) {
|
||||||
return _history.toList();
|
return _history.toList();
|
||||||
@@ -85,13 +111,34 @@ class LiveChatManager {
|
|||||||
try {
|
try {
|
||||||
while(_startCounter == counter) {
|
while(_startCounter == counter) {
|
||||||
var nextInterval = 1000L;
|
var nextInterval = 1000L;
|
||||||
|
if(_pager is JSVODEventPager && _eventsPosition > _position) {
|
||||||
|
delay(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if(_pager == null || !_pager.hasMorePages())
|
if(_pager == null || !_pager.hasMorePages())
|
||||||
return@launch;
|
return@launch;
|
||||||
_pager.nextPage();
|
val newEvents = if(_pager is JSVODEventPager) {
|
||||||
val newEvents = _pager.getResults();
|
val requestPosition = _position;
|
||||||
|
_pager.nextPage(requestPosition.toInt());
|
||||||
|
var replayResults = _pager.getResults().filter { it.time > requestPosition || it is LiveEventEmojis };
|
||||||
|
if(replayResults.size > 0) {
|
||||||
|
_eventsPosition = replayResults.maxOf { it.time };
|
||||||
|
Logger.i(TAG, "VOD Events last event: " + _eventsPosition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
_eventsPosition = requestPosition + _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
replayResults;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_pager.nextPage();
|
||||||
|
_pager.getResults();
|
||||||
|
}
|
||||||
if(_pager is JSLiveEventPager)
|
if(_pager is JSLiveEventPager)
|
||||||
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
else if(_pager is JSVODEventPager)
|
||||||
|
nextInterval = _pager.nextRequest.coerceAtLeast(800).toLong();
|
||||||
|
|
||||||
if(newEvents.size > 0)
|
if(newEvents.size > 0)
|
||||||
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
Logger.i(TAG, "New Live Events (${newEvents.size}) [${newEvents.map { it.type.name }.joinToString(", ")}]");
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ data class PlatformClientCapabilities(
|
|||||||
val hasGetContentChapters: Boolean = false,
|
val hasGetContentChapters: Boolean = false,
|
||||||
val hasPeekChannelContents: Boolean = false,
|
val hasPeekChannelContents: Boolean = false,
|
||||||
val hasGetChannelPlaylists: Boolean = false,
|
val hasGetChannelPlaylists: Boolean = false,
|
||||||
val hasGetContentRecommendations: Boolean = false
|
val hasGetContentRecommendations: Boolean = false,
|
||||||
|
val hasGetUserHistory: Boolean = false
|
||||||
) {
|
) {
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -13,13 +13,17 @@ class PlatformClientPool {
|
|||||||
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
private val _pool: HashMap<JSClient, Int> = hashMapOf();
|
||||||
private var _poolCounter = 0;
|
private var _poolCounter = 0;
|
||||||
private val _poolName: String?;
|
private val _poolName: String?;
|
||||||
|
private val _privatePool: Boolean;
|
||||||
|
private val _isolatedInitialization: Boolean
|
||||||
|
|
||||||
var isDead: Boolean = false
|
var isDead: Boolean = false
|
||||||
private set;
|
private set;
|
||||||
val onDead = Event2<JSClient, PlatformClientPool>();
|
val onDead = Event2<JSClient, PlatformClientPool>();
|
||||||
|
|
||||||
constructor(parentClient: IPlatformClient, name: String? = null) {
|
constructor(parentClient: IPlatformClient, name: String? = null, privatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||||
_poolName = name;
|
_poolName = name;
|
||||||
|
_privatePool = privatePool;
|
||||||
|
_isolatedInitialization = isolatedInitialization
|
||||||
if(parentClient !is JSClient)
|
if(parentClient !is JSClient)
|
||||||
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
throw IllegalArgumentException("Pooling only supported for JSClients right now");
|
||||||
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
Logger.i(TAG, "Pool for ${parentClient.name} was started");
|
||||||
@@ -30,8 +34,10 @@ class PlatformClientPool {
|
|||||||
isDead = true;
|
isDead = true;
|
||||||
onDead.emit(parentClient, this);
|
onDead.emit(parentClient, this);
|
||||||
|
|
||||||
for(clientPair in _pool) {
|
synchronized(_pool) {
|
||||||
clientPair.key.disable();
|
for (clientPair in _pool) {
|
||||||
|
clientPair.key.disable();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -51,7 +57,7 @@ class PlatformClientPool {
|
|||||||
reserved = _pool.keys.find { !it.isBusy };
|
reserved = _pool.keys.find { !it.isBusy };
|
||||||
if(reserved == null && _pool.size < capacity) {
|
if(reserved == null && _pool.size < capacity) {
|
||||||
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
Logger.i(TAG, "Started additional [${_parent.name}] client in pool [${_poolName}] (${_pool.size + 1}/${capacity})");
|
||||||
reserved = _parent.getCopy();
|
reserved = _parent.getCopy(_privatePool, _isolatedInitialization);
|
||||||
|
|
||||||
reserved?.onCaptchaException?.subscribe { client, ex ->
|
reserved?.onCaptchaException?.subscribe { client, ex ->
|
||||||
StateApp.instance.handleCaptchaException(client, ex);
|
StateApp.instance.handleCaptchaException(client, ex);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.getOrThrowNullable
|
import com.futo.platformplayer.getOrThrowNullable
|
||||||
@@ -44,6 +45,7 @@ class PlatformID {
|
|||||||
val NONE = PlatformID("Unknown", null);
|
val NONE = PlatformID("Unknown", null);
|
||||||
|
|
||||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
|
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformID {
|
||||||
|
value.ensureIsBusy();
|
||||||
val contextName = "PlatformID";
|
val contextName = "PlatformID";
|
||||||
return PlatformID(
|
return PlatformID(
|
||||||
value.getOrThrow(config, "platform", contextName),
|
value.getOrThrow(config, "platform", contextName),
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ class PlatformMultiClientPool {
|
|||||||
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
private val _clientPools: HashMap<IPlatformClient, PlatformClientPool> = hashMapOf();
|
||||||
|
|
||||||
private var _isFake = false;
|
private var _isFake = false;
|
||||||
|
private var _privatePool = false;
|
||||||
|
private val _isolatedInitialization: Boolean
|
||||||
|
|
||||||
constructor(name: String, maxCap: Int = -1) {
|
constructor(name: String, maxCap: Int = -1, isPrivatePool: Boolean = false, isolatedInitialization: Boolean = false) {
|
||||||
_name = name;
|
_name = name;
|
||||||
_maxCap = if(maxCap > 0)
|
_maxCap = if(maxCap > 0)
|
||||||
maxCap
|
maxCap
|
||||||
else 99;
|
else 99;
|
||||||
|
_privatePool = isPrivatePool;
|
||||||
|
_isolatedInitialization = isolatedInitialization
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
fun getClientPooled(parentClient: IPlatformClient, capacity: Int = _maxCap): IPlatformClient {
|
||||||
@@ -19,7 +23,7 @@ class PlatformMultiClientPool {
|
|||||||
return parentClient;
|
return parentClient;
|
||||||
val pool = synchronized(_clientPools) {
|
val pool = synchronized(_clientPools) {
|
||||||
if(!_clientPools.containsKey(parentClient))
|
if(!_clientPools.containsKey(parentClient))
|
||||||
_clientPools[parentClient] = PlatformClientPool(parentClient, _name).apply {
|
_clientPools[parentClient] = PlatformClientPool(parentClient, _name, _privatePool, _isolatedInitialization).apply {
|
||||||
this.onDead.subscribe { _, pool ->
|
this.onDead.subscribe { _, pool ->
|
||||||
synchronized(_clientPools) {
|
synchronized(_clientPools) {
|
||||||
if(_clientPools[parentClient] == pool)
|
if(_clientPools[parentClient] == pool)
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ import kotlinx.serialization.json.Json
|
|||||||
|
|
||||||
class Serializer {
|
class Serializer {
|
||||||
companion object {
|
companion object {
|
||||||
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; };
|
val json = Json { ignoreUnknownKeys = true; encodeDefaults = true; coerceInputValues = true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,11 @@ package com.futo.platformplayer.api.media.models
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.ContentType
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.JSContent
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@@ -27,7 +31,10 @@ open class PlatformAuthorLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
val UNKNOWN = PlatformAuthorLink(PlatformID.NONE, "Unknown", "", null, null);
|
||||||
|
|
||||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorLink {
|
||||||
|
value.ensureIsBusy();
|
||||||
if(value.has("membershipUrl"))
|
if(value.has("membershipUrl"))
|
||||||
return PlatformAuthorMembershipLink.fromV8(config, value);
|
return PlatformAuthorMembershipLink.fromV8(config, value);
|
||||||
|
|
||||||
@@ -40,4 +47,23 @@ open class PlatformAuthorLink {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IPlatformChannelContent : IPlatformContent {
|
||||||
|
val thumbnail: String?
|
||||||
|
val subscribers: Long?
|
||||||
|
}
|
||||||
|
|
||||||
|
open class JSChannelContent(
|
||||||
|
config: SourcePluginConfig,
|
||||||
|
obj: V8ValueObject
|
||||||
|
) : JSContent(config, obj), IPlatformChannelContent {
|
||||||
|
|
||||||
|
final override val contentType: ContentType = ContentType.CHANNEL
|
||||||
|
|
||||||
|
override val thumbnail: String? =
|
||||||
|
_content.getOrDefault<String>(_pluginConfig, "thumbnail", "Channel", null)
|
||||||
|
|
||||||
|
override val subscribers: Long? =
|
||||||
|
_content.getOrDefault<Long>(_pluginConfig, "subscribers", "Channel", null)?.toLong()
|
||||||
|
}
|
||||||
|
|||||||
+2
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models
|
|||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class PlatformAuthorMembershipLink: PlatformAuthorLink {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
|
fun fromV8(config: SourcePluginConfig, value: V8ValueObject): PlatformAuthorMembershipLink {
|
||||||
|
value.ensureIsBusy();
|
||||||
val context = "AuthorMembershipLink"
|
val context = "AuthorMembershipLink"
|
||||||
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
return PlatformAuthorMembershipLink(PlatformID.fromV8(config, value.getOrThrow(config, "id", context, false)),
|
||||||
value.getOrThrow(config ,"name", context),
|
value.getOrThrow(config ,"name", context),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.caoccao.javet.values.primitive.V8ValueInteger
|
|||||||
import com.caoccao.javet.values.reference.V8ValueArray
|
import com.caoccao.javet.values.reference.V8ValueArray
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.expectV8Variant
|
import com.futo.platformplayer.expectV8Variant
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
@@ -30,6 +31,7 @@ class ResultCapabilities(
|
|||||||
const val TYPE_POSTS = "POSTS";
|
const val TYPE_POSTS = "POSTS";
|
||||||
const val TYPE_MIXED = "MIXED";
|
const val TYPE_MIXED = "MIXED";
|
||||||
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
const val TYPE_SUBSCRIPTIONS = "SUBSCRIPTIONS";
|
||||||
|
const val TYPE_SHORTS = "SHORTS";
|
||||||
|
|
||||||
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
const val ORDER_CHONOLOGICAL = "CHRONOLOGICAL";
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class ResultCapabilities(
|
|||||||
|
|
||||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities {
|
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): ResultCapabilities {
|
||||||
val contextName = "ResultCapabilities";
|
val contextName = "ResultCapabilities";
|
||||||
|
value.ensureIsBusy();
|
||||||
return ResultCapabilities(
|
return ResultCapabilities(
|
||||||
value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
|
value.getOrThrow<V8ValueArray>(config, "types", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.types") },
|
||||||
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
|
value.getOrThrow<V8ValueArray>(config, "sorts", contextName).toArray().map { it.expectV8Variant(config, "Capabilities.sorts"); },
|
||||||
@@ -68,6 +71,7 @@ class FilterGroup(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup {
|
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): FilterGroup {
|
||||||
|
value.ensureIsBusy();
|
||||||
return FilterGroup(
|
return FilterGroup(
|
||||||
value.getString("name"),
|
value.getString("name"),
|
||||||
value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null)
|
value.getOrDefault<V8ValueArray>(config, "filters", "FilterGroup", null)
|
||||||
@@ -89,6 +93,7 @@ class FilterCapability(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(obj: V8ValueObject): FilterCapability {
|
fun fromV8(obj: V8ValueObject): FilterCapability {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val value = obj.get("value") as V8Value;
|
val value = obj.get("value") as V8Value;
|
||||||
return FilterCapability(
|
return FilterCapability(
|
||||||
obj.getString("name"),
|
obj.getString("name"),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
|||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
import com.futo.platformplayer.engine.V8PluginConfig
|
import com.futo.platformplayer.engine.V8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ class Thumbnails {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
fun fromV8(config: IV8PluginConfig, value: V8ValueObject): Thumbnails {
|
||||||
|
value.ensureIsBusy();
|
||||||
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
return Thumbnails((value.getOrThrow<V8ValueArray>(config, "sources", "Thumbnails"))
|
||||||
.toArray()
|
.toArray()
|
||||||
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
.map { Thumbnail.fromV8(config, it as V8ValueObject) }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.futo.platformplayer.api.media.models.article
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
|
||||||
|
interface IPlatformArticle: IPlatformContent {
|
||||||
|
val summary: String?;
|
||||||
|
val thumbnails: Thumbnails?;
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.futo.platformplayer.api.media.models.article
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.models.Thumbnails
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
|
||||||
|
import com.futo.platformplayer.api.media.models.contents.IPlatformContentDetails
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
|
import com.futo.platformplayer.api.media.platforms.js.models.IJSArticleSegment
|
||||||
|
|
||||||
|
interface IPlatformArticleDetails: IPlatformContent, IPlatformArticle, IPlatformContentDetails {
|
||||||
|
val segments: List<IJSArticleSegment>;
|
||||||
|
val rating : IRating;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ enum class ChapterType(val value: Int) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): ChapterType
|
fun fromInt(value: Int): ChapterType
|
||||||
{
|
{
|
||||||
val result = ChapterType.values().firstOrNull { it.value == value };
|
val result = ChapterType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw UnknownPlatformException(value.toString());
|
throw UnknownPlatformException(value.toString());
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.futo.platformplayer.api.media.models.comments
|
||||||
|
|
||||||
|
import com.futo.platformplayer.api.media.IPlatformClient
|
||||||
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.IRating
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
|
import com.futo.platformplayer.api.media.models.ratings.RatingType
|
||||||
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
|
import com.futo.platformplayer.logging.Logger
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
|
class LazyComment: IPlatformComment {
|
||||||
|
private var _commentDeferred: Deferred<IPlatformComment>;
|
||||||
|
private var _commentLoaded: IPlatformComment? = null;
|
||||||
|
private var _commentException: Throwable? = null;
|
||||||
|
|
||||||
|
override val contextUrl: String
|
||||||
|
get() = _commentLoaded?.contextUrl ?: "";
|
||||||
|
override val author: PlatformAuthorLink
|
||||||
|
get() = _commentLoaded?.author ?: PlatformAuthorLink.UNKNOWN;
|
||||||
|
override val message: String
|
||||||
|
get() = _commentLoaded?.message ?: "";
|
||||||
|
override val rating: IRating
|
||||||
|
get() = _commentLoaded?.rating ?: RatingLikes(0);
|
||||||
|
override val date: OffsetDateTime?
|
||||||
|
get() = _commentLoaded?.date ?: OffsetDateTime.MIN;
|
||||||
|
override val replyCount: Int?
|
||||||
|
get() = _commentLoaded?.replyCount ?: 0;
|
||||||
|
|
||||||
|
val isAvailable: Boolean get() = _commentLoaded != null;
|
||||||
|
|
||||||
|
private var _uiHandler: ((LazyComment)->Unit)? = null;
|
||||||
|
|
||||||
|
constructor(commentDeferred: Deferred<IPlatformComment>) {
|
||||||
|
_commentDeferred = commentDeferred;
|
||||||
|
_commentDeferred.invokeOnCompletion {
|
||||||
|
if(it == null) {
|
||||||
|
_commentLoaded = commentDeferred.getCompleted();
|
||||||
|
Logger.i("LazyComment", "Resolved comment");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
_commentException = it;
|
||||||
|
Logger.e("LazyComment", "Resolving comment failed: ${it.message}", it);
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiHandler?.invoke(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getUnderlyingComment(): IPlatformComment? {
|
||||||
|
return _commentLoaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUIHandler(handler: (LazyComment)->Unit){
|
||||||
|
_uiHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment>? {
|
||||||
|
return _commentLoaded?.getReplies(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+11
-21
@@ -6,25 +6,15 @@ import com.futo.platformplayer.api.media.models.ratings.IRating
|
|||||||
import com.futo.platformplayer.api.media.structures.IPager
|
import com.futo.platformplayer.api.media.structures.IPager
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
open class PlatformComment : IPlatformComment {
|
open class PlatformComment(
|
||||||
override val contextUrl: String;
|
override val contextUrl: String,
|
||||||
override val author: PlatformAuthorLink;
|
override val author: PlatformAuthorLink,
|
||||||
override val message: String;
|
override val message: String,
|
||||||
override val rating: IRating;
|
override val rating: IRating,
|
||||||
override val date: OffsetDateTime;
|
override val date: OffsetDateTime,
|
||||||
|
override val replyCount: Int? = null
|
||||||
|
) : IPlatformComment {
|
||||||
|
|
||||||
override val replyCount: Int?;
|
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> =
|
||||||
|
NoCommentsPager()
|
||||||
constructor(contextUrl: String, author: PlatformAuthorLink, msg: String, rating: IRating, date: OffsetDateTime, replyCount: Int? = null) {
|
}
|
||||||
this.contextUrl = contextUrl;
|
|
||||||
this.author = author;
|
|
||||||
this.message = msg;
|
|
||||||
this.rating = rating;
|
|
||||||
this.date = date;
|
|
||||||
this.replyCount = replyCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getReplies(client: IPlatformClient): IPager<IPlatformComment> {
|
|
||||||
return NoCommentsPager();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ enum class ContentType(val value: Int) {
|
|||||||
POST(2),
|
POST(2),
|
||||||
ARTICLE(3),
|
ARTICLE(3),
|
||||||
PLAYLIST(4),
|
PLAYLIST(4),
|
||||||
|
WEB(7),
|
||||||
|
|
||||||
URL(9),
|
URL(9),
|
||||||
|
|
||||||
NESTED_VIDEO(11),
|
NESTED_VIDEO(11),
|
||||||
|
CHANNEL(60),
|
||||||
|
|
||||||
LOCKED(70),
|
LOCKED(70),
|
||||||
|
|
||||||
@@ -21,7 +23,7 @@ enum class ContentType(val value: Int) {
|
|||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): ContentType
|
fun fromInt(value: Int): ContentType
|
||||||
{
|
{
|
||||||
val result = ContentType.values().firstOrNull { it.value == value };
|
val result = ContentType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw UnknownPlatformException(value.toString());
|
throw UnknownPlatformException(value.toString());
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
+2
@@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.contents
|
|||||||
|
|
||||||
import com.futo.platformplayer.api.media.PlatformID
|
import com.futo.platformplayer.api.media.PlatformID
|
||||||
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonNames
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
|
|
||||||
interface IPlatformContent {
|
interface IPlatformContent {
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package com.futo.platformplayer.api.media.models.live
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
interface IPlatformLiveEvent {
|
interface IPlatformLiveEvent {
|
||||||
val type : LiveEventType;
|
val type : LiveEventType;
|
||||||
|
var time: Long;
|
||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "LiveEvent") : IPlatformLiveEvent {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
val t = LiveEventType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||||
return when(t) {
|
return when(t) {
|
||||||
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
|
LiveEventType.COMMENT -> LiveEventComment.fromV8(config, obj);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.caoccao.javet.values.reference.V8ValueArray
|
|||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@@ -17,16 +18,21 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
val colorName: String?;
|
val colorName: String?;
|
||||||
val badges: List<String>;
|
val badges: List<String>;
|
||||||
|
|
||||||
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null) {
|
override var time: Long = -1;
|
||||||
|
|
||||||
|
constructor(name: String, thumbnail: String?, message: String, colorName: String? = null, badges: List<String>? = null, time: Long = -1) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.message = message;
|
this.message = message;
|
||||||
this.thumbnail = thumbnail;
|
this.thumbnail = thumbnail;
|
||||||
this.colorName = colorName;
|
this.colorName = colorName;
|
||||||
this.badges = badges ?: listOf();
|
this.badges = badges ?: listOf();
|
||||||
|
this.time = time;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventComment {
|
||||||
|
obj.ensureIsBusy();
|
||||||
|
|
||||||
val contextName = "LiveEventComment"
|
val contextName = "LiveEventComment"
|
||||||
|
|
||||||
val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null);
|
val colorName = obj.getOrDefault<String>(config, "colorName", contextName, null);
|
||||||
@@ -36,7 +42,8 @@ class LiveEventComment: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
obj.getOrThrow(config, "name", contextName),
|
obj.getOrThrow(config, "name", contextName),
|
||||||
obj.getOrThrow(config, "thumbnail", contextName, true),
|
obj.getOrThrow(config, "thumbnail", contextName, true),
|
||||||
obj.getOrThrow(config, "message", contextName),
|
obj.getOrThrow(config, "message", contextName),
|
||||||
colorName, badges);
|
colorName, badges,
|
||||||
|
obj.getOrDefault(config, "time", contextName, -1) ?: -1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
import com.futo.platformplayer.api.media.models.ratings.RatingLikes
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrDefault
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
@@ -20,6 +21,8 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
|
|
||||||
var expire: Int = 6000;
|
var expire: Int = 6000;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
|
|
||||||
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
constructor(name: String, thumbnail: String?, message: String, amount: String, expire: Int = 6000, colorDonation: String? = null) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -37,6 +40,7 @@ class LiveEventDonation: IPlatformLiveEvent, ILiveEventChatMessage {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventDonation {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val contextName = "LiveEventDonation"
|
val contextName = "LiveEventDonation"
|
||||||
return LiveEventDonation(
|
return LiveEventDonation(
|
||||||
obj.getOrThrow(config, "name", contextName),
|
obj.getOrThrow(config, "name", contextName),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
class LiveEventEmojis: IPlatformLiveEvent {
|
class LiveEventEmojis: IPlatformLiveEvent {
|
||||||
@@ -9,15 +10,17 @@ class LiveEventEmojis: IPlatformLiveEvent {
|
|||||||
|
|
||||||
val emojis: HashMap<String, String>;
|
val emojis: HashMap<String, String>;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
constructor(emojis: HashMap<String, String>) {
|
constructor(emojis: HashMap<String, String>) {
|
||||||
this.emojis = emojis;
|
this.emojis = emojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventEmojis {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val contextName = "LiveEventEmojis"
|
val contextName = "LiveEventEmojis"
|
||||||
return LiveEventEmojis(
|
return LiveEventEmojis(obj.getOrThrow(config, "emojis", contextName));
|
||||||
obj.getOrThrow(config, "emojis", contextName));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.futo.platformplayer.api.media.models.live
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
|
import com.futo.platformplayer.getOrDefault
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
class LiveEventRaid: IPlatformLiveEvent {
|
class LiveEventRaid: IPlatformLiveEvent {
|
||||||
@@ -10,20 +12,26 @@ class LiveEventRaid: IPlatformLiveEvent {
|
|||||||
val targetName: String;
|
val targetName: String;
|
||||||
val targetThumbnail: String;
|
val targetThumbnail: String;
|
||||||
val targetUrl: String;
|
val targetUrl: String;
|
||||||
|
val isOutgoing: Boolean;
|
||||||
|
|
||||||
constructor(name: String, url: String, thumbnail: String) {
|
override var time: Long = -1;
|
||||||
|
|
||||||
|
constructor(name: String, url: String, thumbnail: String, isOutgoing: Boolean) {
|
||||||
this.targetName = name;
|
this.targetName = name;
|
||||||
this.targetUrl = url;
|
this.targetUrl = url;
|
||||||
this.targetThumbnail = thumbnail;
|
this.targetThumbnail = thumbnail;
|
||||||
|
this.isOutgoing = isOutgoing;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventRaid {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val contextName = "LiveEventRaid"
|
val contextName = "LiveEventRaid"
|
||||||
return LiveEventRaid(
|
return LiveEventRaid(
|
||||||
obj.getOrThrow(config, "targetName", contextName),
|
obj.getOrThrow(config, "targetName", contextName),
|
||||||
obj.getOrThrow(config, "targetUrl", contextName),
|
obj.getOrThrow(config, "targetUrl", contextName),
|
||||||
obj.getOrThrow(config, "targetThumbnail", contextName));
|
obj.getOrThrow(config, "targetThumbnail", contextName),
|
||||||
|
obj.getOrDefault<Boolean>(config, "isOutgoing", contextName, true) ?: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,7 @@ enum class LiveEventType(val value : Int) {
|
|||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
fun fromInt(value : Int) : LiveEventType{
|
fun fromInt(value : Int) : LiveEventType{
|
||||||
return LiveEventType.values().first { it.value == value };
|
return LiveEventType.entries.first { it.value == value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.live
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
class LiveEventViewCount: IPlatformLiveEvent {
|
class LiveEventViewCount: IPlatformLiveEvent {
|
||||||
@@ -9,12 +10,15 @@ class LiveEventViewCount: IPlatformLiveEvent {
|
|||||||
|
|
||||||
val viewCount: Int;
|
val viewCount: Int;
|
||||||
|
|
||||||
|
override var time: Long = -1;
|
||||||
|
|
||||||
constructor(viewCount: Int) {
|
constructor(viewCount: Int) {
|
||||||
this.viewCount = viewCount;
|
this.viewCount = viewCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : LiveEventViewCount {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val contextName = "LiveEventViewCount"
|
val contextName = "LiveEventViewCount"
|
||||||
return LiveEventViewCount(
|
return LiveEventViewCount(
|
||||||
obj.getOrThrow(config, "viewCount", contextName));
|
obj.getOrThrow(config, "viewCount", contextName));
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ import com.futo.platformplayer.api.media.exceptions.UnknownPlatformException
|
|||||||
enum class TextType(val value: Int) {
|
enum class TextType(val value: Int) {
|
||||||
RAW(0),
|
RAW(0),
|
||||||
HTML(1),
|
HTML(1),
|
||||||
MARKUP(2);
|
MARKUP(2),
|
||||||
|
CODE(3);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int): TextType
|
fun fromInt(value: Int): TextType
|
||||||
{
|
{
|
||||||
val result = TextType.values().firstOrNull { it.value == value };
|
val result = TextType.entries.firstOrNull { it.value == value };
|
||||||
if(result == null)
|
if(result == null)
|
||||||
throw IllegalArgumentException("Unknown Texttype: $value");
|
throw IllegalArgumentException("Unknown Texttype: $value");
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||||||
import com.caoccao.javet.values.V8Value
|
import com.caoccao.javet.values.V8Value
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
import com.futo.platformplayer.orDefault
|
import com.futo.platformplayer.orDefault
|
||||||
import com.futo.platformplayer.serializers.IRatingSerializer
|
import com.futo.platformplayer.serializers.IRatingSerializer
|
||||||
@@ -13,8 +14,12 @@ interface IRating {
|
|||||||
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating) = obj.orDefault(default) { fromV8(config, it as V8ValueObject) };
|
fun fromV8OrDefault(config: IV8PluginConfig, obj: V8Value?, default: IRating): IRating {
|
||||||
|
obj?.ensureIsBusy();
|
||||||
|
return obj.orDefault(default) { fromV8(config, it as V8ValueObject) }
|
||||||
|
};
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject, contextName: String = "Rating") : IRating {
|
||||||
|
obj.ensureIsBusy();
|
||||||
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
val t = RatingType.fromInt(obj.getOrThrow<Int>(config, "type", contextName));
|
||||||
return when(t) {
|
return when(t) {
|
||||||
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
|
RatingType.LIKES -> RatingLikes.fromV8(config, obj);
|
||||||
|
|||||||
+2
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,7 @@ class RatingLikeDislikes(val likes: Long, val dislikes: Long) : IRating {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikeDislikes {
|
||||||
|
obj.ensureIsBusy();
|
||||||
return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes"));
|
return RatingLikeDislikes(obj.getOrThrow(config, "likes", "RatingLikeDislikes"), obj.getOrThrow(config, "dislikes", "RatingLikeDislikes"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,7 @@ class RatingLikes(val likes: Long) : IRating {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingLikes {
|
||||||
|
obj.ensureIsBusy();
|
||||||
return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes"));
|
return RatingLikes(obj.getOrThrow(config, "likes", "RatingLikes"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.ratings
|
|||||||
|
|
||||||
import com.caoccao.javet.values.reference.V8ValueObject
|
import com.caoccao.javet.values.reference.V8ValueObject
|
||||||
import com.futo.platformplayer.engine.IV8PluginConfig
|
import com.futo.platformplayer.engine.IV8PluginConfig
|
||||||
|
import com.futo.platformplayer.ensureIsBusy
|
||||||
import com.futo.platformplayer.getOrThrow
|
import com.futo.platformplayer.getOrThrow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,6 +14,7 @@ class RatingScaler(val value: Float) : IRating {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler {
|
fun fromV8(config: IV8PluginConfig, obj: V8ValueObject) : RatingScaler {
|
||||||
|
obj.ensureIsBusy()
|
||||||
return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler"));
|
return RatingScaler(obj.getOrThrow(config, "value", "RatingScaler"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ enum class RatingType(val value : Int) {
|
|||||||
|
|
||||||
companion object{
|
companion object{
|
||||||
fun fromInt(value : Int) : RatingType{
|
fun fromInt(value : Int) : RatingType{
|
||||||
return RatingType.values().first { it.value == value };
|
return RatingType.entries.first { it.value == value };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user